Conditions: empty, error, loading
In short: Conditional rendering shows only the part of the markup that matches the data (loading, error, empty or the list); in Vue with v-if, v-else and v-show.
Problem
Loading, error and the list at once
The catalog has loaded. Under the heading, though, it says "Loading…", then "The products did not load.", then "There are no products in this category.", and only then come the products.
Console
The Catalog component gets status and the products as props. But it does not look at them: it always draws all four parts. The buyer does not know what to believe, and a screen reader reads both the error and the products.
The idea
The part that matches the data
The catalog has four states: loading, error, empty and ready. Only the part for the current one should be on the screen. This is conditional rendering: the component decides what to return based on the data.
In Vue the conditions are attributes in the template:
<p v-if="status === 'loading'" role="status">Loading…</p><p v-else-if="status === 'error'" role="alert">The products did not load.</p><p v-else-if="status === 'empty'">There are no products in this category.</p><ul v-else>…</ul>v-if draws the element only if the expression is true. v-else-if and v-else continue the chain and must come right after the previous element of it. Exactly one element of the chain is drawn.
The two libraries solve the same thing in their own way: React with JavaScript around the markup, Vue with attributes inside it. The result is the same: the element of a false condition is not in the DOM. It is not hidden, it does not exist.
Here status is given as a prop. How the catalog changes it by itself while loading from the API is in the module "Effects, data and forms".
See it
See which branch goes in
One value goes into the component, and only one branch goes into the DOM. Watch the "DOM" column on every change of status.
<template> <p v-if="status === 'loading'">Loading…</p> <p v-else-if="status === 'error'">Error</p> <p v-else-if="status === 'empty'">No products</p> <ul v-else>…</ul></template>
<!-- in the DOM, but hidden --><p v-show="status === 'loading'">Loading…</p>Step by step, in words
- status comes into the component as a prop. It is 'loading': v-if matches, and only <p>Loading…</p> is drawn. The other branches are not in the DOM at all.
- status becomes 'error'. The library draws again: now the second branch matches. <p>Loading…</p> leaves the DOM, and the error message comes in.
- 'empty': only the message that the category is empty.
- 'ready': none of v-if and v-else-if matches, so v-else is drawn: the list. One branch at a time is in the DOM.
- The other way: v-show always draws the element and only switches its display. With status loading, it is visible.
- status becomes 'ready'. The element stays in the DOM, with what is in it, and only gets display: none. Nobody sees it, and a screen reader skips it.
- v-if removes the element from the DOM, v-show only hides it. v-if is the default; v-show is for something that is toggled often or must keep what was typed.
The first part is conditional rendering: on a new value the old element leaves the DOM and the new one comes in. The second part is hiding: the element is in the DOM the whole time and is only shown or hidden. The end of the lesson is about it.
Try it
The four states
Catalog draws everything at once. Make it so that every section has only the part of its state: "Loading…" for loading, the error for error, "There are no products in this category." for empty and the list for ready. The other parts must not be on the page.
Console
Solution
Catalog.vue<script setup>
import ProductCard from './ProductCard.vue';
defineProps({ status: String, products: Array });
</script>
<template>
<p v-if="status === 'loading'" role="status">Loading…</p>
<p v-else-if="status === 'error'" role="alert">The products did not load.</p>
<p v-else-if="status === 'empty'">There are no products in this category.</p>
<ul v-else>
<li v-for="p in products" :key="p.id">
<ProductCard :product="p" />
</li>
</ul>
</template>
App.vue<script setup>
import Catalog from './Catalog.vue';
const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2, stock: 5 },
{ id: 3, name: 'White cheese', price: 7.9, stock: 0 },
{ id: 2, name: 'Fresh milk', price: 1.6, stock: 2 },
];
</script>
<template>
<main>
<section aria-label="Loading">
<h2>Loading</h2>
<Catalog status="loading" :products="[]" />
</section>
<section aria-label="Error">
<h2>Error</h2>
<Catalog status="error" :products="[]" />
</section>
<section aria-label="Empty category">
<h2>Empty category</h2>
<Catalog status="empty" :products="[]" />
</section>
<section aria-label="Ready">
<h2>Ready</h2>
<Catalog status="ready" :products="PRODUCTS" />
</section>
</main>
</template>
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => '€' + x.toFixed(2);
</script>
<template>
<article>
<h3>{{ product.name }}</h3>
<p>{{ money(product.price) }}</p>
<button type="button" :aria-label="`Add ${product.name}`">Add</button>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
section { border: 1px dashed #bbb; border-radius: 8px; padding: 8px 12px; margin-bottom: 10px; }
section > h2 { font-size: 13px; color: #666; margin: 0 0 6px; text-transform: uppercase; }
ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
article { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 6px 12px; }
article h3 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 4px 0; }
[role="alert"] { color: #b3261e; }
.badge { background: #fde2dd; color: #8c1d18; border-radius: 4px; padding: 0 6px; font-size: 13px; }
button { font-size: 15px; padding: 4px 12px; }
textarea { display: block; width: 100%; box-sizing: border-box; margin-top: 6px; }
v-if, two v-else-if and v-else on the <ul>. The check looks for both the messages and the list in every section. If the <ul> has no v-else, it is also in the "Loading" section, even though it is empty: a <ul> without rows is still a list for a screen reader.Try it
Fill in
"Loading…" should show only while status is loading.
<p ="status === 'loading'" role="status">Loading…</p>v-if: the element is drawn only if the expression is true.
The idea
Sold out: a badge and a disabled button
A condition is not only for whole parts. In the product card it changes a text and an attribute:
<p v-if="product.stock === 0" class="badge">Sold out</p><p v-else>Only {{ product.stock }} left</p><button type="button" :disabled="product.stock === 0">Add</button>:disabled="true" puts the disabled attribute on, and :disabled="false" removes it.
A disabled button cannot be pressed, does not take the focus with Tab, and a screen reader says that it is unavailable. The badge says why. A button that can be pressed and then says "out of stock" is worse: the buyer finds out only after the click.
Try it
Sold out
Make ProductCard show "Sold out" and disable the "Add" button when stock is 0. Otherwise it shows "Only 5 left" and the button works. The cheese is sold out.
Console
Solution
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => '€' + x.toFixed(2);
</script>
<template>
<article>
<h3>{{ product.name }}</h3>
<p>{{ money(product.price) }}</p>
<p v-if="product.stock === 0" class="badge">Sold out</p>
<p v-else>Only {{ product.stock }} left</p>
<button type="button" :aria-label="`Add ${product.name}`" :disabled="product.stock === 0">Add</button>
</article>
</template>
App.vue<script setup>
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2, stock: 5 },
{ id: 3, name: 'White cheese', price: 7.9, stock: 0 },
{ id: 2, name: 'Fresh milk', price: 1.6, stock: 2 },
];
</script>
<template>
<main>
<h1>The catalog</h1>
<ul>
<li v-for="p in PRODUCTS" :key="p.id">
<ProductCard :product="p" />
</li>
</ul>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
section { border: 1px dashed #bbb; border-radius: 8px; padding: 8px 12px; margin-bottom: 10px; }
section > h2 { font-size: 13px; color: #666; margin: 0 0 6px; text-transform: uppercase; }
ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
article { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 6px 12px; }
article h3 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 4px 0; }
[role="alert"] { color: #b3261e; }
.badge { background: #fde2dd; color: #8c1d18; border-radius: 4px; padding: 0 6px; font-size: 13px; }
button { font-size: 15px; padding: 4px 12px; }
textarea { display: block; width: 100%; box-sizing: border-box; margin-top: 6px; }
v-if and v-else pick between the badge and "Only … left", and :disabled="product.stock === 0" disables the button. If the condition is flipped (v-if="product.stock"), the badge is on the products in stock, and the cheese says "Only 0 left".The idea
The zero on the screen
v-if looks at whether the value is truthy, so v-if="items.length" with an empty cart draws nothing. The zero comes out when the condition is in {{ }}:
<p>{{ items.length && `In the cart: ${items.length}` }}</p> <!-- empty cart: 0 --><p v-if="items.length > 0">In the cart: {{ items.length }}</p> <!-- empty cart: nothing -->&& does not return true or false. It returns the left side if that is falsy: here 0, and {{ }} shows it. {{ false }} even shows the word "false". Write conditions with v-if, and keep {{ }} for values.
Try it
Find the bug
With an empty cart, the top corner says "0".
Pick the line with the bug.
Line 3: items.length && … is 0 for an empty cart, and {{ }} shows it. It has to be <p v-if="items.length > 0">In the cart: {{ items.length }}</p>.
The idea
Removed or hidden
Sometimes an element has to stay in the DOM and only not be seen. Here both panels have a field for a note. Open both, type something, close them and open them again.
Console
v-if: when closed it is gone, and when opened it is new, with an empty field. In the second it uses v-show: it stays in the DOM with display: none and keeps what was typed.Removing is the default: fewer elements on the page, nothing hidden for a screen reader. Hiding is for parts that are opened and closed often or have to keep what was entered in them.
More precisely, if you are curious
v-if on <template>. To show several elements under one condition without a wrapper, put v-if on a <template>: it does not go into the DOM. v-show does not work on <template>, because there is no element to put display: none on.
The cost. v-if makes and removes the elements on every change, while v-show makes them once, on the first render, and then only switches display. So v-show is for parts that change often, and v-if for parts that are shown rarely.
v-if and v-for. In Vue 3 a v-if on the same element as v-for runs first and does not see the loop variable. Put the v-if on a wrapper or filter the data.
The Options API in old code uses the same v-if, v-else and v-show.
aria-hidden is not hiding. aria-hidden="true" hides the element only from a screen reader, and it stays on the screen. Hiding from everybody is display: none: hidden, v-show or removing.
Exercise
The cart
App shows three carts: empty, with products and with a sold out product. Make Cart so that the empty one says "The cart is empty.", with no list and no total, and the "Place order" button is disabled. The full one shows the rows, "Total: €4.00" and a working button. The sold out product has a "Sold out" badge, above the button it says "Remove the sold out products to place the order." (role alert), and the button is disabled.
Console
Solution
Cart.vue<script setup>
const props = defineProps({ items: Array });
const money = (x) => '€' + x.toFixed(2);
const total = () => props.items.reduce((sum, p) => sum + p.price * p.qty, 0);
const soldOut = () => props.items.some((p) => p.stock === 0);
</script>
<template>
<div>
<p v-if="items.length === 0">The cart is empty.</p>
<template v-else>
<ul>
<li v-for="p in items" :key="p.id">
{{ p.name }} × {{ p.qty }}
<span v-if="p.stock === 0" class="badge">Sold out</span>
</li>
</ul>
<p>Total: {{ money(total()) }}</p>
</template>
<p v-if="soldOut()" role="alert">Remove the sold out products to place the order.</p>
<button type="button" :disabled="items.length === 0 || soldOut()">Place order</button>
</div>
</template>
App.vue<script setup>
import Cart from './Cart.vue';
</script>
<template>
<main>
<section aria-label="Empty">
<h2>Empty</h2>
<Cart :items="[]" />
</section>
<section aria-label="With products">
<h2>With products</h2>
<Cart :items="[
{ id: 1, name: 'White bread', price: 1.2, qty: 2, stock: 5 },
{ id: 2, name: 'Fresh milk', price: 1.6, qty: 1, stock: 2 },
]" />
</section>
<section aria-label="With a sold out product">
<h2>With a sold out product</h2>
<Cart :items="[
{ id: 1, name: 'White bread', price: 1.2, qty: 1, stock: 5 },
{ id: 3, name: 'White cheese', price: 7.9, qty: 1, stock: 0 },
]" />
</section>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
section { border: 1px dashed #bbb; border-radius: 8px; padding: 8px 12px; margin-bottom: 10px; }
section > h2 { font-size: 13px; color: #666; margin: 0 0 6px; text-transform: uppercase; }
ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
article { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 6px 12px; }
article h3 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 4px 0; }
[role="alert"] { color: #b3261e; }
.badge { background: #fde2dd; color: #8c1d18; border-radius: 4px; padding: 0 6px; font-size: 13px; }
button { font-size: 15px; padding: 4px 12px; }
textarea { display: block; width: 100%; box-sizing: border-box; margin-top: 6px; }
v-if on the message and v-else on a <template> with the list and the total: so the two switch as one. v-if="soldOut()" shows the warning, and :disabled="items.length === 0 || soldOut()" disables the button in both cases. Without v-else the list is also in the empty cart, and the check looks for it.Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Data from an API · The first component · Props: data from above · Lists and keys
- Continues in: Updating objects and arrays · Derived state · Lifting state up · Effects
- Tasks on this topic: Components