Listas y renderizado condicional
Ya has visto v-for y v-if en lecciones anteriores, pero esta lección va más allá. Las apps reales necesitan manejar listas dinámicas — añadir, eliminar, reordenar elementos — y mostrar diferentes estados de interfaz: cargando, error, vacío o con datos. Dominar estos patrones es esencial para construir cualquier aplicación frontend seria.
v-for en profundidad
Array de objetos con :key
Ya sabes que v-for itera sobre un array. Pero hay una regla de oro: siempre usa :key con un identificador único. No uses el índice del array como key — eso causa bugs sutiles cuando añades, eliminas o reordenas elementos.
<script setup>
import { ref } from 'vue'
const tripulacion = ref([
{ id: 1, nombre: 'Nova', rol: 'Piloto', activo: true },
{ id: 2, nombre: 'Orion', rol: 'Ingeniera', activo: true },
{ id: 3, nombre: 'Vega', rol: 'Científica', activo: false },
{ id: 4, nombre: 'Atlas', rol: 'Médico', activo: true },
])
</script>
<template>
<!-- BIEN: usa un id único como :key -->
<div v-for="miembro in tripulacion" :key="miembro.id" class="miembro-card">
<h3>{{ miembro.nombre }}</h3>
<p>{{ miembro.rol }}</p>
<span>{{ miembro.activo ? 'En servicio' : 'Fuera de servicio' }}</span>
</div>
<!-- MAL: nunca uses el índice como :key -->
<!-- <div v-for="(miembro, index) in tripulacion" :key="index"> -->
</template>
Por qué :key importa tanto
Vue usa un algoritmo de diffing del Virtual DOM para actualizar la interfaz de forma eficiente. Cuando una lista cambia, Vue compara los elementos anteriores con los nuevos usando las keys. Sin una key estable:
- Si eliminas un elemento del medio, Vue reutiliza los nodos DOM incorrectos y el estado interno de los componentes se mezcla.
- Si reordenas la lista, las animaciones y transiciones no funcionan correctamente.
- Los inputs dentro de un
v-forpueden mostrar el valor del elemento equivocado.
Con un :key único (como un id de base de datos), Vue sabe exactamente qué elemento cambió, se añadió o se eliminó, y actualiza solo lo necesario.
v-for con índice, objetos y rangos
<script setup>
import { ref } from 'vue'
const planetas = ref(['Mercurio', 'Venus', 'Tierra', 'Marte'])
const nave = ref({
nombre: 'Stellar-7',
tipo: 'Exploración',
velocidad: '25000 km/h',
tripulantes: 6
})
</script>
<template>
<!-- Con índice -->
<p v-for="(planeta, index) in planetas" :key="planeta">
{{ index + 1 }}. {{ planeta }}
</p>
<!-- Iterar sobre propiedades de un objeto -->
<div v-for="(valor, clave) in nave" :key="clave">
<strong>{{ clave }}:</strong> {{ valor }}
</div>
<!-- Rango: genera números del 1 al 5 -->
<span v-for="n in 5" :key="n" class="estrella">
{{ n <= 3 ? '★' : '☆' }}
</span>
</template>
template v-for: múltiples elementos raíz por iteración
A veces necesitas renderizar varios elementos por cada iteración sin un contenedor extra. Usa <template v-for> — no genera ningún elemento DOM adicional:
<script setup>
import { ref } from 'vue'
const registros = ref([
{ id: 1, fecha: '2030-03-15', evento: 'Lanzamiento exitoso', detalle: 'Velocidad de escape alcanzada' },
{ id: 2, fecha: '2030-03-16', evento: 'Órbita estabilizada', detalle: 'Sistemas nominales' },
])
</script>
<template>
<dl>
<!-- template no genera un elemento DOM wrapper -->
<template v-for="registro in registros" :key="registro.id">
<dt>{{ registro.fecha }} — {{ registro.evento }}</dt>
<dd>{{ registro.detalle }}</dd>
</template>
</dl>
</template>
Operaciones dinámicas con listas
Las listas estáticas son aburridas. En apps reales, los usuarios añaden, eliminan y modifican elementos constantemente. Veamos las operaciones más comunes:
<script setup>
import { ref } from 'vue'
let nextId = 4
const tareas = ref([
{ id: 1, texto: 'Verificar sistemas de navegación', completada: false },
{ id: 2, texto: 'Calibrar sensores de oxígeno', completada: true },
{ id: 3, texto: 'Revisar escudos térmicos', completada: false },
])
const nuevaTarea = ref('')
// Añadir al final
const agregarTarea = () => {
if (!nuevaTarea.value.trim()) return
tareas.value.push({
id: nextId++,
texto: nuevaTarea.value.trim(),
completada: false
})
nuevaTarea.value = ''
}
// Añadir al inicio
const agregarTareaUrgente = () => {
if (!nuevaTarea.value.trim()) return
tareas.value.unshift({
id: nextId++,
texto: `⚠️ ${nuevaTarea.value.trim()}`,
completada: false
})
nuevaTarea.value = ''
}
// Eliminar con filter (crea un nuevo array)
const eliminarTarea = (id) => {
tareas.value = tareas.value.filter(t => t.id !== id)
}
// Alternar completada
const toggleTarea = (id) => {
const tarea = tareas.value.find(t => t.id === id)
if (tarea) tarea.completada = !tarea.completada
}
// Reordenar
const ordenarPorEstado = () => {
tareas.value.sort((a, b) => Number(a.completada) - Number(b.completada))
}
// Invertir orden
const invertirOrden = () => {
tareas.value.reverse()
}
</script>
<template>
<div class="tareas-app">
<h2>Tareas de Misión</h2>
<div class="tareas-input">
<input
v-model="nuevaTarea"
@keyup.enter="agregarTarea"
placeholder="Nueva tarea..."
/>
<button @click="agregarTarea">Añadir</button>
<button @click="agregarTareaUrgente">Urgente</button>
</div>
<div class="tareas-acciones">
<button @click="ordenarPorEstado">Ordenar por estado</button>
<button @click="invertirOrden">Invertir orden</button>
</div>
<ul class="tareas-lista">
<li
v-for="tarea in tareas"
:key="tarea.id"
:class="{ completada: tarea.completada }"
>
<input
type="checkbox"
:checked="tarea.completada"
@change="toggleTarea(tarea.id)"
/>
<span>{{ tarea.texto }}</span>
<button @click="eliminarTarea(tarea.id)">✕</button>
</li>
</ul>
<p>{{ tareas.length }} tarea(s) en total</p>
</div>
</template>
Dato importante: métodos como
push(),splice(),sort()yreverse()mutan el array original. Como usamosref(), Vue detecta estos cambios automáticamente.filter()ymap()crean un array nuevo, así que debes reasignar contareas.value = ....
v-if vs v-show — la imagen completa
Ambas directivas controlan la visibilidad de elementos, pero funcionan de forma muy diferente internamente.
| Característica | v-if |
v-show |
|---|---|---|
| Mecanismo | Añade/elimina del DOM | Alterna display: none |
| Renderizado inicial | Lazy — no renderiza si es false |
Siempre renderiza el elemento |
| Costo de cambio | Alto (crear/destruir componente) | Bajo (solo cambiar CSS) |
| Ideal para | Condiciones que cambian poco | Toggles frecuentes (tabs, menús) |
<script setup>
import { ref } from 'vue'
const motorEncendido = ref(false)
const panelAbierto = ref(true)
const nivelAcceso = ref('admin')
</script>
<template>
<!-- v-if: elimina del DOM cuando es false -->
<div v-if="motorEncendido" class="motor-activo">
Motor principal: ENCENDIDO
</div>
<!-- v-show: siempre en el DOM, solo oculta con CSS -->
<!-- Ideal para cosas que se muestran/ocultan seguido -->
<div v-show="panelAbierto" class="panel-lateral">
Panel de navegación
</div>
<!-- Cadena v-if / v-else-if / v-else -->
<div v-if="nivelAcceso === 'admin'">
Acceso total al sistema
</div>
<div v-else-if="nivelAcceso === 'piloto'">
Acceso a controles de vuelo
</div>
<div v-else-if="nivelAcceso === 'tripulante'">
Acceso básico
</div>
<div v-else>
Sin acceso autorizado
</div>
<button @click="motorEncendido = !motorEncendido">
Toggle motor
</button>
<button @click="panelAbierto = !panelAbierto">
Toggle panel
</button>
</template>
v-if en template para agrupar sin wrapper
Al igual que con v-for, puedes usar <template v-if> para mostrar/ocultar condicionalmente un grupo de elementos sin añadir un contenedor extra al DOM:
Misión en curso
Destino: {{ destino }}
Tiempo restante: {{ tiempoRestante }}
Sin misión activa
Esperando órdenes del comando central.
' ])Regla práctica: usa
v-showpara elementos que se muestran y ocultan con frecuencia (tabs, paneles colapsables, tooltips). Usav-ifpara condiciones que rara vez cambian o cuando no quieres que el componente exista en el DOM en absoluto (por ejemplo, un modal que solo se carga cuando se necesita).
Patrón de estados de carga
Este es uno de los patrones más importantes en el desarrollo frontend. Casi toda pantalla que muestra datos de una API pasa por tres estados: cargando, error y datos (que puede estar vacío o con contenido). Dominar este patrón te va a servir en Vue, React, Angular o cualquier framework.
<script setup>
import { ref, onMounted } from 'vue'
const misiones = ref([])
const cargando = ref(true)
const error = ref(null)
// Simular una petición a la API
const cargarMisiones = async () => {
cargando.value = true
error.value = null
try {
// Simulamos un delay de red
await new Promise(resolve => setTimeout(resolve, 1500))
// Simular respuesta (en una app real sería un fetch/axios)
misiones.value = [
{ id: 1, nombre: 'Artemis IV', destino: 'Luna', estado: 'activa' },
{ id: 2, nombre: 'Odyssey', destino: 'Europa', estado: 'planificación' },
{ id: 3, nombre: 'Pioneer X', destino: 'Marte', estado: 'completada' },
]
} catch (err) {
error.value = 'Error al cargar las misiones. Inténtalo de nuevo.'
} finally {
cargando.value = false
}
}
onMounted(() => {
cargarMisiones()
})
</script>
<template>
<div class="misiones-panel">
<h2>Misiones Espaciales</h2>
<!-- Estado: Cargando -->
<div v-if="cargando" class="estado-cargando">
<div class="spinner"></div>
<p>Consultando base de datos orbital...</p>
</div>
<!-- Estado: Error -->
<div v-else-if="error" class="estado-error">
<p>{{ error }}</p>
<button @click="cargarMisiones">Reintentar</button>
</div>
<!-- Estado: Sin datos -->
<div v-else-if="misiones.length === 0" class="estado-vacio">
<p>No hay misiones registradas.</p>
<p>¡Es hora de planificar la primera exploración!</p>
</div>
<!-- Estado: Con datos -->
<div v-else>
<ul>
<li v-for="mision in misiones" :key="mision.id">
<strong>{{ mision.nombre }}</strong>
— {{ mision.destino }}
<span :class="'estado-' + mision.estado">
({{ mision.estado }})
</span>
</li>
</ul>
<p>{{ misiones.length }} misión(es) encontrada(s)</p>
</div>
</div>
</template>
El orden de la cadena v-if es intencional:
- Cargando — siempre va primero, porque mientras cargamos no sabemos si habrá error o datos.
- Error — si no estamos cargando y hay un error, lo mostramos con un botón de reintento.
- Vacío — si no hay error pero tampoco hay datos, mostramos un estado vacío amigable.
- Datos — el
v-elsefinal es el "camino feliz", donde todo salió bien y hay datos para mostrar.
Memoriza este patrón. Lo vas a usar en prácticamente todas las pantallas que consuman datos externos.
Transiciones básicas
Vue incluye componentes integrados para añadir animaciones de entrada y salida. No vamos a profundizar aquí, pero es importante que conozcas los conceptos básicos.
Transition: un solo elemento
El componente <Transition> aplica animaciones CSS cuando un elemento entra o sale del DOM (con v-if o v-show):
<script setup>
import { ref } from 'vue'
const mostrarAlerta = ref(false)
</script>
<template>
<button @click="mostrarAlerta = !mostrarAlerta">
Toggle alerta
</button>
<Transition name="fade">
<div v-if="mostrarAlerta" class="alerta">
¡Alerta! Asteroide detectado a 500 km.
</div>
</Transition>
</template>
<style scoped>
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
</style>
Las clases de transición siguen un patrón predecible. Si tu <Transition> tiene name="fade":
.fade-enter-from— estado inicial al entrar.fade-enter-active— se aplica durante toda la entrada.fade-enter-to— estado final al entrar (generalmente no lo defines porque es el estado normal).fade-leave-from— estado inicial al salir (generalmente el estado normal).fade-leave-active— se aplica durante toda la salida.fade-leave-to— estado final al salir
TransitionGroup: listas animadas
<TransitionGroup> es para animar elementos dentro de un v-for. Cada elemento necesita un :key único (que ya deberías tener):
<script setup>
import { ref } from 'vue'
let nextId = 4
const items = ref([
{ id: 1, texto: 'Verificar propulsores' },
{ id: 2, texto: 'Comprobar oxígeno' },
{ id: 3, texto: 'Activar escudos' },
])
const nuevoTexto = ref('')
const agregar = () => {
if (!nuevoTexto.value.trim()) return
items.value.push({ id: nextId++, texto: nuevoTexto.value.trim() })
nuevoTexto.value = ''
}
const eliminar = (id) => {
items.value = items.value.filter(item => item.id !== id)
}
</script>
<template>
<input v-model="nuevoTexto" @keyup.enter="agregar" placeholder="Nuevo elemento..." />
<button @click="agregar">Añadir</button>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.texto }}
<button @click="eliminar(item.id)">✕</button>
</li>
</TransitionGroup>
</template>
<style scoped>
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-enter-active,
.list-leave-active {
transition: all 0.4s ease;
}
/* Animación para los elementos que se mueven cuando otro se elimina */
.list-move {
transition: transform 0.4s ease;
}
/* Necesario para que list-move funcione al eliminar */
.list-leave-active {
position: absolute;
}
</style>
Fíjate en la clase .list-move: es la que hace que los demás elementos se desplacen suavemente cuando uno se elimina o se añade. Sin ella, los elementos "saltan" a su nueva posición.
Resumen
v-for— itera sobre arrays, objetos y rangos. Siempre usa:keycon un id único, nunca el índice.<template v-for>— renderiza múltiples elementos raíz por iteración sin wrapper extra.- Operaciones de lista —
push(),filter(),splice(),sort()yreverse()para manipular arrays reactivos. v-if— añade/elimina del DOM. Ideal para condiciones poco frecuentes. Soportav-else-ifyv-else.v-show— alternadisplay: none. Ideal para toggles frecuentes.- Patrón de estados — cargando, error, vacío y datos. Memoriza este patrón.
<Transition>— anima un solo elemento al entrar/salir del DOM.<TransitionGroup>— anima elementos de una lista conv-for.
Gestor de Tareas para una Misión Espacial
Construye un gestor de tareas de misión espacial con las siguientes funcionalidades:
- Añadir tareas con un input de texto y un selector de prioridad (
alta,media,baja). Cada tarea debe tener unidúnico,texto,prioridadycompletada(boolean). - Eliminar tareas con un botón en cada tarea.
- Filtrar por prioridad — botones para mostrar "Todas", "Alta", "Media" y "Baja". Usa una propiedad
computedpara filtrar la lista según la prioridad seleccionada. - Alternar completada — un checkbox en cada tarea para marcarla como completada.
- Estado vacío — cuando no haya tareas que coincidan con el filtro activo, muestra un mensaje como "No hay tareas con prioridad [X]" usando
v-if. - Contadores — muestra el total de tareas y cuántas están completadas.
- Animaciones — envuelve la lista en
<TransitionGroup>para animar la entrada y salida de tareas.
Bonus: asigna un color visual a cada prioridad (rojo para alta, amarillo para media, verde para baja) y aplica un estilo tachado a las tareas completadas.
lightbulb Pistas
Crea un ref llamado filtroActivo con valor "todas". Usa un computed para generar la lista filtrada: const tareasFiltradas = computed(() => filtroActivo.value === "todas" ? tareas.value : tareas.value.filter(t => t.prioridad === filtroActivo.value)). Para el TransitionGroup, usa name="task" y define las clases .task-enter-from, .task-leave-to (con opacity: 0 y un translateX) y .task-enter-active, .task-leave-active (con transition: all 0.3s ease). No olvides .task-move y position: absolute en .task-leave-active.