Vue – Conditional and List Rendering

October 30, 20254 min readUpdated 8/24/2026

Two directives, and a surprising amount of detail behind them.

v-if

v-if puts an element in the DOM or leaves it out. When it is false the element does not exist — it is not hidden, it is not there, and its component is not created.

<p v-if="message" class="text-secondary small mb-3">{{ message }}</p>

v-else-if and v-else chain from it, and must be on the immediately following element. The search page uses the full chain to switch between three states:

  <LoadingSpinner v-if="loading" label="Searching…" />

  <EmptyState
    v-else-if="!results.content.length"
    icon="bi-search"
    title="Nothing matched"
    message="Try a broader term, or clear the filters."
  >
    <button class="btn btn-sm btn-outline-light" @click="router.push({ name: 'explore' })">
      Clear filters
    </button>
  </EmptyState>

  <template v-else>

Loading, empty, or results — exactly one renders, and the states cannot overlap. That is worth more than it looks: writing this as three separate v-ifs eventually produces a spinner and an empty state on screen at the same time.

v-if on a <template>

To condition a group of elements without adding a wrapper div, put the directive on a <template>. It renders its children and nothing of itself:

<template v-if="route.query.q">Results for “<strong>{{ route.query.q }}</strong></template>

This matters more than it sounds when you are working inside a CSS grid or a flex row, where a stray wrapper changes the layout.

v-show

v-show always renders the element and toggles display: none.

<div v-show="isOpen">Always in the DOM, sometimes display:none</div>

The difference is where the cost lands. v-if is cheap to leave off and expensive to toggle — mounting and unmounting real components. v-show is the opposite: it pays the render cost once, up front, then toggling is one CSS property.

Use v-if by default. Reach for v-show when something toggles often — a dropdown, a tab — and is not expensive to keep mounted. Note also that v-show does not work on <template>, because there is no element to set a style on.

v-for

<span v-for="tag in modelValue" :key="tag" class="tag-chip">
  #{{ tag }}
</span>

It also gives you an index, iterates objects, and takes a plain number:

<li v-for="(reel, index) in reels" :key="reel.id">{{ index + 1 }}. {{ reel.title }}</li>

<li v-for="(value, key) in meta" :key="key">{{ key }}: {{ value }}</li>

<span v-for="n in 5" :key="n">{{ n }}</span>   <!-- 1 to 5, not 0 to 4 -->

of works in place of in if you prefer it. There is no difference.

:key, and why it matters

This is the part worth slowing down for.

When a list changes, Vue reuses existing DOM elements rather than rebuilding the list. To do that it has to work out which new item corresponds to which old element. :key is how you tell it.

Give each item a stable, unique key — a database id, not a name that might repeat:

<div v-for="reel in results.content" :key="reel.id" class="col">
  <ReelCard :reel="reel" />
</div>

Why the index is usually wrong

:key="index" is the default behaviour written out by hand, and it is fine for a list that never reorders or deletes. The moment it does, you get bugs that look like the framework is broken.

Say you render three toasts keyed by index and dismiss the first. The second becomes index 0, so Vue decides element 0 is being updated rather than removed. Any state living in the DOM rather than in your data — an input's typed text, a video's playback position, focus, a CSS transition mid-flight — stays with the element and attaches itself to the wrong item.

The demo application's toasts are keyed :key="t.id" from an incrementing counter for precisely this reason: they are dismissed out of order and animate as they go.

The key must be unique among siblings

Duplicate keys produce a warning and genuinely strange rendering. If you have no natural unique value, make one when you create the item rather than deriving one at render time.

Do not put v-if and v-for on the same element

<!-- WRONG -->
<li v-for="reel in reels" v-if="reel.published" :key="reel.id">{{ reel.title }}</li>

In Vue 3 v-if has the higher priority, so it is evaluated first — and it references reel, which does not exist yet. Vue warns about this.

Filter in a computed, which is faster anyway because it caches:

const published = computed(() => reels.value.filter((r) => r.published));
<li v-for="reel in published" :key="reel.id">{{ reel.title }}</li>

Or, if you really want it in the template, wrap with a <template v-for> and put the v-if inside.

Arrays and reactivity

Vue 3 uses proxies, so all the mutating array methods just work — push, splice, sort, reverse. So does assigning by index and setting length, both of which needed workarounds in Vue 2.

reels.value.push(...res.items);       // reactive
reels.value[0] = updated;             // reactive in Vue 3
reels.value = reels.value.filter(f);  // also fine

One thing that is not a proxy: Set and Map mutations are tracked, but the demo application still reassigns after mutating — liked.value = new Set(liked.value) — because a template reading liked.has(id) depends on the identity, not the contents. If a Set-backed computed is not updating, that is why.

Next: Components.