Saltar al contenido
schedule 10 min Vue

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.

TripulacionList.vue
<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-for pueden 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

PatronesVFor.vue
<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:

LogMision.vue
<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:

ListaTareas.vue
<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() y reverse() mutan el array original. Como usamos ref(), Vue detecta estos cambios automáticamente. filter() y map() crean un array nuevo, así que debes reasignar con tareas.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)
CondicionalDemo.vue
<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:

@include('front.web-course.components.code-block', [ 'language' => 'markup', 'code' => ' ' ])

Regla práctica: usa v-show para elementos que se muestran y ocultan con frecuencia (tabs, paneles colapsables, tooltips). Usa v-if para 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.

MisionesPanel.vue
<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:

  1. Cargando — siempre va primero, porque mientras cargamos no sabemos si habrá error o datos.
  2. Error — si no estamos cargando y hay un error, lo mostramos con un botón de reintento.
  3. Vacío — si no hay error pero tampoco hay datos, mostramos un estado vacío amigable.
  4. Datos — el v-else final 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):

AlertaMision.vue
<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):

ListaAnimada.vue
<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 :key con un id único, nunca el índice.
  • <template v-for> — renderiza múltiples elementos raíz por iteración sin wrapper extra.
  • Operaciones de listapush(), filter(), splice(), sort() y reverse() para manipular arrays reactivos.
  • v-if — añade/elimina del DOM. Ideal para condiciones poco frecuentes. Soporta v-else-if y v-else.
  • v-show — alterna display: 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 con v-for.
code

Gestor de Tareas para una Misión Espacial

Medio schedule 20 min

Construye un gestor de tareas de misión espacial con las siguientes funcionalidades:

  1. Añadir tareas con un input de texto y un selector de prioridad (alta, media, baja). Cada tarea debe tener un id único, texto, prioridad y completada (boolean).
  2. Eliminar tareas con un botón en cada tarea.
  3. Filtrar por prioridad — botones para mostrar "Todas", "Alta", "Media" y "Baja". Usa una propiedad computed para filtrar la lista según la prioridad seleccionada.
  4. Alternar completada — un checkbox en cada tarea para marcarla como completada.
  5. 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.
  6. Contadores — muestra el total de tareas y cuántas están completadas.
  7. 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.