Condiciones: vacío, error, cargando
En resumen: El renderizado condicional muestra solo la parte del marcado que corresponde a los datos (cargando, error, vacío o la lista); en Vue con v-if, v-else y v-show.
Problema
Cargando, error y la lista a la vez
El catálogo se ha cargado. Pero bajo el encabezado dice «Cargando…», luego «Los productos no se cargaron.», luego «No hay productos en esta categoría.», y solo después vienen los productos.
Consola
El componente Catalog recibe status y los productos como props. Pero no los mira: siempre dibuja las cuatro partes. El comprador no sabe qué creer, y un lector de pantalla lee tanto el error como los productos.
La idea
La parte que corresponde a los datos
El catálogo tiene cuatro estados: cargando, error, vacío y listo. En la pantalla debe estar solo la parte del estado actual. Esto es el renderizado condicional: el componente decide qué devolver según los datos.
En Vue las condiciones son atributos en la plantilla:
<p v-if="status === 'loading'" role="status">Cargando…</p><p v-else-if="status === 'error'" role="alert">Los productos no se cargaron.</p><p v-else-if="status === 'empty'">No hay productos en esta categoría.</p><ul v-else>…</ul>v-if dibuja el elemento solo si la expresión es verdadera. v-else-if y v-else continúan la cadena y deben ir justo después del elemento anterior de ella. Se dibuja exactamente un elemento de la cadena.
Las dos bibliotecas resuelven lo mismo a su manera: React con JavaScript alrededor del marcado, Vue con atributos dentro. El resultado es el mismo: el elemento de una condición falsa no está en el DOM. No está oculto, no existe.
Aquí status se pasa como prop. Cómo lo cambia el propio catálogo mientras carga desde la API está en el módulo «Efectos, datos y formularios».
Míralo
Ver qué rama entra
Un valor entra en el componente, y solo una rama entra en el DOM. Fíjate en la columna «DOM» en cada cambio de status.
<template> <p v-if="status === 'loading'">Cargando…</p> <p v-else-if="status === 'error'">Error</p> <p v-else-if="status === 'empty'">No hay productos</p> <ul v-else>…</ul></template>
<!-- en el DOM, pero oculto --><p v-show="status === 'loading'">Cargando…</p>Paso a paso, en palabras
- status llega al componente como prop. Es 'loading': v-if coincide, y se dibuja solo <p>Cargando…</p>. Las otras ramas no están en el DOM.
- status pasa a 'error'. La biblioteca dibuja otra vez: ahora coincide la segunda rama. <p>Cargando…</p> sale del DOM, y entra el mensaje de error.
- 'empty': solo el mensaje de que la categoría está vacía.
- 'ready': ni v-if ni v-else-if coinciden, así que se dibuja v-else: la lista. En el DOM hay siempre una rama.
- La otra manera: v-show dibuja siempre el elemento y solo cambia su display. Con loading, se ve.
- status pasa a 'ready'. El elemento se queda en el DOM, con todo lo que tiene dentro, y solo recibe display: none. Nadie lo ve, y un lector de pantalla lo salta.
- v-if quita el elemento del DOM, v-show solo lo oculta. v-if es lo habitual; v-show es para algo que se cambia a menudo o que debe conservar lo escrito.
La primera parte es el renderizado condicional: con un valor nuevo, el elemento viejo sale del DOM y entra el nuevo. La segunda parte es ocultar: el elemento está en el DOM todo el tiempo y solo se muestra o se oculta. De eso trata el final de la lección.
Pruébalo
Los cuatro estados
Catalog dibuja todo a la vez. Haz que cada sección tenga solo la parte de su estado: «Cargando…» para cargando, el error para error, «No hay productos en esta categoría.» para vacío y la lista para listo. Las otras partes no deben estar en la página.
Consola
Solución
Catalog.vue<script setup>
import ProductCard from './ProductCard.vue';
defineProps({ status: String, products: Array });
</script>
<template>
<p v-if="status === 'loading'" role="status">Cargando…</p>
<p v-else-if="status === 'error'" role="alert">Los productos no se cargaron.</p>
<p v-else-if="status === 'empty'">No hay productos en esta categoría.</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: 'Pan blanco', price: 1.2, stock: 5 },
{ id: 3, name: 'Queso blanco', price: 7.9, stock: 0 },
{ id: 2, name: 'Leche fresca', price: 1.6, stock: 2 },
];
</script>
<template>
<main>
<section aria-label="Cargando">
<h2>Cargando</h2>
<Catalog status="loading" :products="[]" />
</section>
<section aria-label="Error">
<h2>Error</h2>
<Catalog status="error" :products="[]" />
</section>
<section aria-label="Categoría vacía">
<h2>Categoría vacía</h2>
<Catalog status="empty" :products="[]" />
</section>
<section aria-label="Listo">
<h2>Listo</h2>
<Catalog status="ready" :products="PRODUCTS" />
</section>
</main>
</template>
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<h3>{{ product.name }}</h3>
<p>{{ money(product.price) }}</p>
<button type="button" :aria-label="`Añadir ${product.name}`">Añadir</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, dos v-else-if y v-else en el <ul>. La comprobación busca en cada sección tanto los mensajes como la lista. Si el <ul> no tiene v-else, también está en la sección «Cargando», aunque esté vacío: un <ul> sin filas sigue siendo una lista para un lector de pantalla.Pruébalo
Completa el hueco
«Cargando…» debe aparecer solo mientras status es loading.
<p ="status === 'loading'" role="status">Cargando…</p>v-if: el elemento se dibuja solo si la expresión es verdadera.
La idea
Agotado: una insignia y un botón desactivado
Una condición no es solo para partes enteras. En la tarjeta del producto cambia un texto y un atributo:
<p v-if="product.stock === 0" class="badge">Agotado</p><p v-else>Quedan {{ product.stock }} uds.</p><button type="button" :disabled="product.stock === 0">Añadir</button>:disabled="true" pone el atributo disabled, y :disabled="false" lo quita.
Un botón desactivado no se puede pulsar, no recibe el foco con Tab, y un lector de pantalla dice que no está disponible. La insignia dice por qué. Un botón que se puede pulsar y luego dice «sin existencias» es peor: el comprador se entera solo después del clic.
Pruébalo
Agotado
Haz que ProductCard muestre «Agotado» y desactive el botón «Añadir» cuando stock sea 0. Si no, muestra «Quedan 5 uds.» y el botón funciona. El queso está agotado.
Consola
Solución
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<h3>{{ product.name }}</h3>
<p>{{ money(product.price) }}</p>
<p v-if="product.stock === 0" class="badge">Agotado</p>
<p v-else>Quedan {{ product.stock }} uds.</p>
<button type="button" :aria-label="`Añadir ${product.name}`" :disabled="product.stock === 0">Añadir</button>
</article>
</template>
App.vue<script setup>
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2, stock: 5 },
{ id: 3, name: 'Queso blanco', price: 7.9, stock: 0 },
{ id: 2, name: 'Leche fresca', price: 1.6, stock: 2 },
];
</script>
<template>
<main>
<h1>El catálogo</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 y v-else eligen entre la insignia y «Quedan … uds.», y :disabled="product.stock === 0" desactiva el botón. Si la condición está invertida (v-if="product.stock"), la insignia sale en los productos disponibles, y el queso dice «Quedan 0 uds.».La idea
El cero en la pantalla
v-if mira si el valor es verdadero, así que v-if="items.length" con un carrito vacío no dibuja nada. El cero aparece cuando la condición está en {{ }}:
<p>{{ items.length && `En el carrito: ${items.length}` }}</p> <!-- carrito vacío: 0 --><p v-if="items.length > 0">En el carrito: {{ items.length }}</p> <!-- carrito vacío: nada -->&& no devuelve true o false. Devuelve el lado izquierdo si es falsy: aquí 0, y {{ }} lo muestra. {{ false }} incluso muestra la palabra «false». Escribe las condiciones con v-if, y deja {{ }} para los valores.
Pruébalo
Encuentra el error
Con el carrito vacío, en la esquina de arriba pone «0».
Elige la línea con el error.
Línea 3: items.length && … es 0 con el carrito vacío, y {{ }} lo muestra. Tiene que ser <p v-if="items.length > 0">En el carrito: {{ items.length }}</p>.
La idea
Quitado u oculto
A veces un elemento tiene que quedarse en el DOM y solo no verse. Aquí los dos paneles tienen un campo para una nota. Abre los dos, escribe algo, ciérralos y ábrelos otra vez.
Consola
v-if: cerrado desaparece, y abierto es nuevo, con el campo vacío. En la segunda usa v-show: se queda en el DOM con display: none y conserva lo escrito.Quitar es lo habitual: menos elementos en la página, nada oculto para un lector de pantalla. Ocultar es para partes que se abren y se cierran a menudo o tienen que conservar lo que se introdujo en ellas.
Más precisamente, para curiosos
v-if en <template>. Para mostrar varios elementos bajo una condición sin envoltorio, pon v-if en un <template>: no entra en el DOM. v-show no funciona en <template>, porque no hay un elemento al que poner display: none.
El coste. v-if hace y quita los elementos en cada cambio, mientras que v-show los hace una vez, en el primer dibujado, y después solo cambia display. Así que v-show es para partes que cambian a menudo, y v-if para partes que se muestran pocas veces.
v-if y v-for. En Vue 3 un v-if en el mismo elemento que v-for se ejecuta primero y no ve la variable del bucle. Pon el v-if en un envoltorio o filtra los datos.
La Options API del código antiguo usa los mismos v-if, v-else y v-show.
aria-hidden no es ocultar. aria-hidden="true" oculta el elemento solo de un lector de pantalla, y se queda en la pantalla. Ocultar para todos es display: none: hidden, v-show o quitar.
Ejercicio
El carrito
App muestra tres carritos: vacío, con productos y con un producto agotado. Haz Cart de modo que el vacío diga «El carrito está vacío.», sin lista y sin total, y el botón «Hacer el pedido» esté desactivado. El lleno muestra las filas, «Total: 4,00 €» y un botón que funciona. El producto agotado tiene una insignia «Agotado», encima del botón dice «Quita los productos agotados para hacer el pedido.» (rol alert), y el botón está desactivado.
Consola
Solución
Cart.vue<script setup>
const props = defineProps({ items: Array });
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
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">El carrito está vacío.</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">Agotado</span>
</li>
</ul>
<p>Total: {{ money(total()) }}</p>
</template>
<p v-if="soldOut()" role="alert">Quita los productos agotados para hacer el pedido.</p>
<button type="button" :disabled="items.length === 0 || soldOut()">Hacer el pedido</button>
</div>
</template>
App.vue<script setup>
import Cart from './Cart.vue';
</script>
<template>
<main>
<section aria-label="Vacío">
<h2>Vacío</h2>
<Cart :items="[]" />
</section>
<section aria-label="Con productos">
<h2>Con productos</h2>
<Cart :items="[
{ id: 1, name: 'Pan blanco', price: 1.2, qty: 2, stock: 5 },
{ id: 2, name: 'Leche fresca', price: 1.6, qty: 1, stock: 2 },
]" />
</section>
<section aria-label="Con un producto agotado">
<h2>Con un producto agotado</h2>
<Cart :items="[
{ id: 1, name: 'Pan blanco', price: 1.2, qty: 1, stock: 5 },
{ id: 3, name: 'Queso blanco', 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 en el mensaje y v-else en un <template> con la lista y el total: así los dos cambian como uno. v-if="soldOut()" muestra el aviso, y :disabled="items.length === 0 || soldOut()" desactiva el botón en ambos casos. Sin v-else la lista también está en el carrito vacío, y la comprobación lo busca.Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Datos desde una API · El primer componente · Props: datos desde arriba · Listas y claves
- Continúa en: Actualizar objetos y arrays · Estado derivado · Estado compartido: hacia arriba · Efectos
- Ejercicios sobre el tema: Componentes