Skip to content
TutorialBeginner

v-if, v-show and v-for: the directives you use every day

Three directives do most of the work in a Vue template. Knowing which one to reach for, and what :key is really for, saves you from the bugs that produce no error message.

vuejs.school team29 Jan 20266 min readVue 3.5

A directive is an attribute that starts with v- and tells Vue to do something to the element it sits on. Three of them appear in almost every component: v-if decides whether an element exists, v-show decides whether it is visible, and v-for repeats it for each item in a list.

Each comes with a choice that is easy to get wrong. Pick the wrong conditional and a panel is rebuilt every time it opens; pick the wrong :key and text typed into one row of a list ends up next to a different item, with no error at all. The longer examples are complete components with explicit imports; the short ones show only the template lines that matter.

v-if removes, v-show hides

Both directives take an expression and react to it. The difference is what they do to the DOM, the tree of elements the browser builds from your HTML.

src/components/Details.vue
<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false)
</script>

<template>
  <button type="button" @click="open = !open">Details</button>
  <p v-if="open">I exist only while open is true.</p>
  <p v-show="open">I am always in the DOM, hidden with display: none.</p>
</template>

With open set to false, the first paragraph is not in the page at all. Vue leaves a comment node as a placeholder (<!--v-if--> in development) and creates the real element when the condition becomes true. The second paragraph is created once, at the start, and Vue toggles the inline style display: none on it. Open your browser's element inspector and click the button a few times: one paragraph appears and disappears from the tree, the other only changes its style attribute.

Two limits follow from that. v-show cannot be used on a <template> element, because a <template> has no element of its own to hide. And v-show has no v-else; if you need a second branch, you need v-if.

Use v-show for frequent toggles, v-if for the rest

v-if is lazy. If the condition starts false, Vue does no work for that branch: no child components are created, no onMounted hooks run, no data is fetched. The cost arrives when the condition flips, because every toggle destroys the subtree and builds it again from nothing. v-show is the reverse: it pays the full rendering cost up front, once, and each toggle afterwards costs one style change.

So choose by how often the thing toggles. A dropdown menu, a tooltip, the panels of a tab bar: these open and close again and again, and v-show keeps them instant. A settings form, a chart that loads a library, content only an administrator may see: these are shown rarely or never, and v-if means the user who never opens them never pays for them.

v-if is also the one to use when the element must not be in the HTML at all, because an element hidden with v-show is still in the DOM and in any HTML rendered on the server. That is not protection, though: the template is compiled into the JavaScript the browser downloads, so data meant only for administrators should come from a server that checks who is asking.

Add branches with v-else-if and v-else

A v-else or v-else-if element must come straight after the v-if or v-else-if element, with no other element in between, or Vue reports a compile error. A loading state is the usual shape (the fetch that sets status and orders is left out):

src/components/Orders.vue
<script setup lang="ts">
import { ref } from 'vue'

type Status = 'loading' | 'error' | 'ready'

const status = ref<Status>('loading')
const orders = ref<string[]>([])
</script>

<template>
  <p v-if="status === 'loading'">Loading your orders…</p>
  <p v-else-if="status === 'error'">We could not load your orders.</p>
  <p v-else-if="orders.length === 0">No orders yet.</p>
  <ul v-else>
    <li v-for="order in orders" :key="order">{{ order }}</li>
  </ul>
</template>

When one branch needs several elements, wrap them in <template v-if="...">. The <template> tag renders nothing itself, so you get a group without an extra <div> in the page.

Loop over arrays, objects and ranges with v-for

v-for takes the form item in items and renders its element once per item. A second name gives you the position: (item, index) in items. You can write of instead of in; they mean the same thing. The :key attribute tells Vue which rendered element belongs to which item, and every v-for should have one. In this to-do list, each row's key is the todo's id:

src/components/TodoList.vue
<script setup lang="ts">
import { ref } from 'vue'

type Todo = { id: number; text: string; done: boolean }

const todos = ref<Todo[]>([
  { id: 1, text: 'Read about directives', done: true },
  { id: 2, text: 'Build a todo list', done: false },
])
</script>

<template>
  <ul>
    <li v-for="todo in todos" :key="todo.id">
      <label>
        <input v-model="todo.done" type="checkbox" />
        {{ todo.text }}
      </label>
    </li>
  </ul>
</template>

Objects work too, with the value first and the key second: (value, key) in settings. A plain number renders a range, and the count starts at one, not zero. Here settings is an object such as { theme: 'dark', fontSize: 16 }, and a <template> groups each <dt> with its <dd>:

<dl>
  <template v-for="(value, key) in settings" :key="key">
    <dt>{{ key }}</dt>
    <dd>{{ value }}</dd>
  </template>
</dl>

<button v-for="page in 5" :key="page" type="button">{{ page }}</button>

A :key must be a string or a number, and it must be unique among its siblings. It should also be stable: the same item must get the same key every render. The id from your database is the normal choice.

Why index keys cause bugs

The tempting shortcut is :key="index". It satisfies the rule that every item has a key, and it works until items change position. Here is a guest list where each row has a text box the user types into; guests is a ref holding objects with an id and a name:

src/components/Guests.vueBreaks
<li v-for="(guest, index) in guests" :key="index">
  {{ guest.name }}
  <input type="text" placeholder="Dietary notes" />
  <button type="button" @click="guests.splice(index, 1)">Remove</button>
</li>

Start with three guests, type a note next to the first, then remove that guest. The note stays in the first row, now next to the second guest's name. Here is what Vue did:

  1. Before the removal the rows had keys 0, 1 and 2. After it, two guests remain, with keys 0 and 1.
  2. Vue matches rows by key, so it kept the <li> elements keyed 0 and 1, updated the name inside each, and removed the one keyed 2.
  3. The <input> is not bound to any data, so its typed value belongs to the DOM element, and that element stayed where it was.

The same thing happens to a child component's own state and to enter and leave transitions. The fix changes only the key:

src/components/Guests.vueWorks
<li v-for="(guest, index) in guests" :key="guest.id">
  {{ guest.name }}
  <input type="text" placeholder="Dietary notes" />
  <button type="button" @click="guests.splice(index, 1)">Remove</button>
</li>

With guest.id as the key, Vue removes the <li> that belonged to the removed guest, text box and all, and leaves the others alone. An index is acceptable only for a list that is never reordered, filtered or spliced, and most lists eventually are.

Keep v-if and v-for on separate elements

Vue 3 evaluates v-if before v-for when both sit on one element. The condition therefore runs outside the loop and cannot see the loop variable:

src/components/TodoList.vueBreaks
<li v-for="todo in todos" v-if="!todo.done" :key="todo.id">
  {{ todo.text }}
</li>

In development the console shows [Vue warn]: Property "todo" was accessed during render but is not defined on instance., and then the render throws a TypeError, because todo is undefined when the condition runs. The lint rule vue/no-use-v-if-with-v-for in eslint-plugin-vue catches this before you run anything.

The fix depends on what you meant. To filter the list, do it in a computed property, which also keeps the template readable:

src/components/TodoList.vueWorks
<script setup lang="ts">
import { computed, ref } from 'vue'

type Todo = { id: number; text: string; done: boolean }

const todos = ref<Todo[]>([])
const openTodos = computed(() => todos.value.filter(todo => !todo.done))
</script>

<template>
  <ul>
    <li v-for="todo in openTodos" :key="todo.id">{{ todo.text }}</li>
  </ul>
</template>

To skip or show the whole list, put the v-if on the wrapping <ul>. If you really need a per-item condition in the template, use <template v-for="todo in todos" :key="todo.id"> with <li v-if="!todo.done"> inside it. In Vue 3 the key goes on the <template>, not on the inner element.

Write v-bind and v-on as : and @

Two more directives are so common that they have one-character forms, and most code uses them, this post included:

  • v-bind:src="url" binds an attribute to an expression and is written :src="url".
  • v-on:click="save" listens for an event and is written @click="save".

Two details about v-bind are worth knowing early. When the variable has the same name as the attribute, Vue 3.4 and later let you write :id on its own instead of :id="id". And v-bind="props" with no attribute name spreads every property of an object onto the element, which is how wrapper components pass attributes through.

Skip updates with v-once and v-memo

v-once renders an element and its children one time and never updates them again, which suits content that is built from props at creation and will not change. v-memo="[selectedId === item.id]" tells Vue to skip re-rendering a subtree unless one of the values in the array changed, and it is a tool for very long v-for lists where most rows are unaffected by an update. Reach for neither until a profiler tells you to.

Check it stuck3 questions from the Directives quiz.
Practice now
Was this helpful?

Keep reading