La lección anterior dejó a <task-card> anunciando, mediante el evento personalizado tarea-cambiada, que el usuario ha elegido un nuevo estado para una tarea. Pero un anuncio sin nadie que lo escuche no sirve de mucho: esta lección cierra el ciclo completo, haciendo que <task-list> reciba ese evento, actualice su propio array tareas y haga que ese array actualizado vuelva a bajar, como propiedad, hacia cada <task-card>. En el camino aparece un concepto que, aunque no es exclusivo de Lit, resulta decisivo para que la reactividad funcione bien: la inmutabilidad de los datos que se guardan en una propiedad reactiva.

Contenido

  1. Recordatorio: de padre a hijo, propiedades
  2. Escuchando un evento personalizado con @evento
  3. Actualizando el array de tareas: el problema de mutar en el sitio
  4. La solución inmutable: crear un array y un objeto nuevos
  5. Por qué la inmutabilidad importa para la detección de cambios de Lit
  6. El ciclo completo en <task-list>
  7. Cierre: hacia los componentes hermanos

  1. Recordatorio: de padre a hijo, propiedades

El módulo 3 ya estableció la vía normal por la que un componente padre entrega datos a un componente hijo: propiedades reactivas, asignadas desde la plantilla del padre con el binding de punto (.propiedad="${valor}") cuando el dato no es una simple cadena de texto. Es exactamente lo que <task-list> ya hace desde el módulo 2 al renderizar cada tarjeta:

${this.tareas.map(
  (tarea) => html`
    <task-card
      .titulo="${tarea.titulo}"
      .estado="${tarea.estado}"
      .prioridad="${tarea.prioridad}"
      .urgente="${tarea.urgente}"
    ></task-card>
  `
)}

No hay nada nuevo que explicar aquí sobre el mecanismo en sí: sigue siendo el mismo binding de propiedad de siempre. Lo que sí es nuevo en esta lección es de dónde sale el valor actualizado de tarea.estado que se vuelve a pasar en cada renderizado: hasta ahora, this.tareas en <task-list> era un array fijo, inicializado una sola vez en el constructor y nunca modificado después. A partir de esta lección, ese array cambia de verdad, como respuesta a lo que ocurre dentro de cada <task-card>.

  1. Escuchando un evento personalizado con @evento

La lección 05-01 presentó @evento para eventos nativos del navegador (@click, @keydown); la buena noticia es que la misma sintaxis, sin ningún cambio, funciona exactamente igual con eventos personalizados como tarea-cambiada, porque, como se explicó en la lección anterior, un CustomEvent es, a todos los efectos del sistema de eventos del navegador, un Event más:

render() {
  return html`
    <div class="lista">
      ${this.tareas.map(
        (tarea) => html`
          <task-card
            .titulo="${tarea.titulo}"
            .estado="${tarea.estado}"
            .prioridad="${tarea.prioridad}"
            .urgente="${tarea.urgente}"
            @tarea-cambiada="${(event) => this.gestionarTareaCambiada(tarea.id, event)}"
          ></task-card>
        `
      )}
    </div>
  `;
}

Aquí aparece un detalle de diseño que merece explicación: el manejador no es directamente this.gestionarTareaCambiada (como en los ejemplos de la lección 05-01), sino una arrow function en línea, (event) => this.gestionarTareaCambiada(tarea.id, event). La razón es que gestionarTareaCambiada necesita saber a qué tarea concreta afecta el cambio, y esa información —el id de la tarea— solo está disponible dentro del cuerpo del Array.map, en la variable tarea de esa iteración concreta; el propio evento tarea-cambiada, tal como se despachó en la lección anterior, solo lleva en su detail el nuevo estado, no ningún identificador de la tarea (porque <task-card>, deliberadamente, no conoce el concepto de "id" ni la estructura del array de <task-list>, como se explicó en el apartado 1 de la lección anterior). La arrow function en línea "captura" el valor de tarea.id de esa iteración concreta y lo añade como argumento adicional al llamar a gestionarTareaCambiada, resolviendo así, en el lado de <task-list> (que sí conoce su propia estructura de datos), la correspondencia entre "qué tarjeta ha emitido el evento" y "qué tarea del array le corresponde".

Como ya se advirtió en la lección 05-01 a propósito de las arrow functions en línea dentro de plantillas, esto tiene el pequeño coste de que Lit no puede reutilizar exactamente el mismo listener entre renderizados sucesivos (cada llamada a render() crea una arrow function nueva). Para el volumen de tarjetas habitual en una lista de tareas, este coste es completamente insignificante, y la claridad de poder capturar tarea.id directamente en la expresión compensa con creces la alternativa de intentar evitarlo.

  1. Actualizando el array de tareas: el problema de mutar en el sitio

Con el evento ya llegando a gestionarTareaCambiada(idTarea, event), la tentación más directa es localizar la tarea correspondiente dentro de this.tareas y modificarla en el sitio:

// Incorrecto: muta el array y el objeto existentes en el sitio
gestionarTareaCambiada(idTarea, event) {
  const tarea = this.tareas.find((t) => t.id === idTarea);
  tarea.estado = event.detail.nuevoEstado; // muta el objeto existente
  this.requestUpdate(); // haría falta forzarlo manualmente
}

Este código, a primera vista, parece razonable, y de hecho "funciona" en el sentido de que el objeto tarea dentro del array cambia su propiedad estado correctamente en memoria. El problema no está en esa mutación en sí, sino en cómo Lit decide si una propiedad reactiva ha cambiado: como se explicó en el módulo 3, cambiar this.tareas dispara una actualización solo si Lit detecta que el valor de la propiedad es distinto al anterior. Y aquí está la trampa: this.tareas sigue siendo literalmente el mismo array (la misma referencia en memoria) antes y después de esta mutación; solo ha cambiado un objeto dentro de él. Lit, para propiedades de tipo objeto o array, compara referencias, no contenido profundo, así que no detecta ningún cambio en absoluto, y ninguna actualización se dispara de forma automática (de ahí el this.requestUpdate() forzado en el ejemplo anterior, un parche que esconde el problema real en lugar de resolverlo correctamente).

  1. La solución inmutable: crear un array y un objeto nuevos

La forma correcta, y la que se usa de aquí en adelante en TaskFlow, es no mutar nunca el array ni los objetos que contiene, sino crear una copia nueva con el cambio ya incorporado:

gestionarTareaCambiada(idTarea, event) {
  const nuevoEstado = event.detail.nuevoEstado;

  this.tareas = this.tareas.map((tarea) =>
    tarea.id === idTarea ? { ...tarea, estado: nuevoEstado } : tarea
  );
}

Esta versión usa dos técnicas de JavaScript estándar, sin nada específico de Lit: Array.prototype.map(), que siempre devuelve un array nuevo (nunca modifica el original), y el operador de propagación de objetos ({ ...tarea, estado: nuevoEstado }), que crea un objeto nuevo copiando todas las propiedades de tarea y sobrescribiendo, a continuación, solo estado con el valor recibido en el evento. Para las tareas que no coinciden con idTarea, map devuelve la misma referencia de objeto que ya tenían (no hace falta copiarlas, porque no han cambiado); solo la tarea afectada obtiene un objeto nuevo. El resultado final, this.tareas = ..., asigna a la propiedad reactiva una referencia de array completamente distinta a la que tenía antes, aunque el contenido de la mayoría de sus elementos sea el mismo objeto de siempre.

  1. Por qué la inmutabilidad importa para la detección de cambios de Lit

Con la nueva asignación, this.tareas = this.tareas.map(...), el setter generado por Lit para la propiedad tareas (el mismo mecanismo visto en el módulo 3 para cualquier propiedad reactiva) compara la referencia anterior de this.tareas con la nueva: son, literalmente, dos arrays distintos en memoria, así que la comparación por defecto de Lit (!==, la comparación de igualdad estricta) detecta la diferencia sin ninguna ambigüedad, y programa una actualización exactamente como con cualquier otra propiedad.

Operación sobre this.tareas ¿Cambia la referencia? ¿Lit detecta el cambio?
this.tareas[0].estado = 'hecha' (mutación directa de un objeto interno) No No
this.tareas.push(nuevaTarea) (mutación directa del array) No No
this.tareas.sort(...) (mutación directa del array, incluso si reordena) No No
this.tareas = this.tareas.map(...)
this.tareas = [...this.tareas, nuevaTarea]
this.tareas = this.tareas.filter(...)

Esta tabla resume una regla general que va más allá de este ejemplo concreto y que conviene interiorizar para cualquier propiedad reactiva de tipo objeto o array en Lit: los métodos de array que modifican en el sitio (push, pop, splice, sort, reverse, o la asignación directa a un índice o a una propiedad anidada) nunca cambian la referencia del array u objeto contenedor, así que nunca disparan una actualización por sí solos; los métodos que devuelven una copia nueva (map, filter, concat, el operador de propagación [...array] o {...objeto}) sí cambian la referencia, y son los que hay que usar siempre que se quiera que Lit reaccione al cambio. No se trata de una limitación arbitraria de Lit: comparar el contenido profundo de un array en cada actualización (en lugar de solo su referencia) sería mucho más costoso computacionalmente para árboles de datos grandes, así que Lit, igual que otras muchas librerías reactivas, opta deliberadamente por la comparación de referencia, rápida y predecible, a cambio de exigir esta disciplina de inmutabilidad en el código que la usa.

  1. El ciclo completo en <task-list>

Con todas las piezas explicadas, el código completo de <task-list> con el nuevo manejador queda así:

// src/components/task-list.js
import { LitElement, html, css } from 'lit';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './task-card.js';

class TaskList extends LitElement {
  static properties = {
    tareas: { type: Array },
  };

  static styles = [
    estilosCompartidos,
    css`
      .lista {
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
      }
    `,
  ];

  constructor() {
    super();
    this.tareas = [
      { id: 1, titulo: 'Preparar la demo del sprint', estado: 'en-progreso', prioridad: 4, urgente: true },
      { id: 2, titulo: 'Revisar el PR de autenticación', estado: 'pendiente', prioridad: 2, urgente: false },
      { id: 3, titulo: 'Desplegar a producción', estado: 'hecha', prioridad: 5, urgente: false },
    ];
  }

  gestionarTareaCambiada(idTarea, event) {
    const nuevoEstado = event.detail.nuevoEstado;
    this.tareas = this.tareas.map((tarea) =>
      tarea.id === idTarea ? { ...tarea, estado: nuevoEstado } : tarea
    );
  }

  render() {
    return html`
      <section>
        <h2>Mis tareas</h2>
        <div class="lista">
          ${this.tareas.map(
            (tarea) => html`
              <task-card
                .titulo="${tarea.titulo}"
                .estado="${tarea.estado}"
                .prioridad="${tarea.prioridad}"
                .urgente="${tarea.urgente}"
                @tarea-cambiada="${(event) => this.gestionarTareaCambiada(tarea.id, event)}"
              ></task-card>
            `
          )}
        </div>
      </section>
    `;
  }
}

customElements.define('task-list', TaskList);

El recorrido completo, de un extremo a otro, es ahora el siguiente: el usuario cambia el <select> de una <task-card> concreta; <task-card> actualiza su propia propiedad estado (para reflejar el cambio visualmente de inmediato, sin depender de recibir de vuelta la confirmación de su padre) y despacha tarea-cambiada con el nuevo estado en detail; <task-list> recibe el evento a través de @tarea-cambiada, identifica gracias al cierre sobre tarea.id a qué tarea del array corresponde, y reemplaza this.tareas por un array nuevo, con un objeto nuevo para esa tarea concreta y las demás intactas; Lit detecta el cambio de referencia en this.tareas y vuelve a ejecutar render(); el Array.map de la plantilla itera de nuevo sobre el array actualizado y pasa, mediante .estado="${tarea.estado}", el nuevo valor hacia cada <task-card> (incluida la que originó el cambio, que recibe de vuelta, como propiedad, exactamente el mismo valor que ya tenía por su propia actualización interna). El ciclo se cierra: un evento subió, una propiedad bajó.

  1. Cierre: hacia los componentes hermanos

Merece la pena notar que este flujo —hijo emite evento, padre escucha y actualiza estado, estado actualizado baja de nuevo como propiedad— es exactamente el mismo patrón, sin ninguna variación conceptual, que se repetirá en el resto del curso cada vez que un componente necesite comunicar un cambio hacia arriba: el nombre del evento cambiará, el detail transportará datos distintos, la lógica de actualización del array será distinta, pero la estructura de fondo (evento personalizado hacia arriba, propiedad reactiva hacia abajo, inmutabilidad en medio) es siempre la misma.

Queda, sin embargo, un escenario que este patrón, tal como está planteado hasta ahora, no resuelve directamente: ¿qué ocurre cuando dos componentes que necesitan comunicarse no tienen una relación directa de padre e hijo, como ocurrirá pronto entre <task-list> y un futuro <task-filter>? Ese es exactamente el tema de la siguiente lección.

Errores Comunes y Consejos

  • Mutar el array o los objetos en el sitio y esperar que Lit reaccione: como se explicó en el apartado 3, this.tareas[0].estado = 'hecha' o this.tareas.push(...) no cambian la referencia de this.tareas, así que Lit no detecta ningún cambio y la interfaz no se actualiza, aunque los datos en memoria sí hayan cambiado.
  • Usar this.requestUpdate() como parche en lugar de corregir la mutación: es posible forzar una actualización manual con this.requestUpdate() tras una mutación directa, y "funciona" en el sentido de que la interfaz se refresca; pero es un síntoma, no una solución, de estar mutando datos que deberían tratarse de forma inmutable, y suele acumular deuda técnica difícil de rastrear más adelante.
  • Copiar solo el nivel más externo cuando el cambio está más adentro: si tarea tuviera, por ejemplo, un objeto anidado (tarea.metadatos.ultimaModificacion), copiar solo { ...tarea } no bastaría para que un cambio dentro de metadatos se reflejara de forma inmutable; haría falta también { ...tarea, metadatos: { ...tarea.metadatos, ultimaModificacion: ahora } }, copiando cada nivel de anidamiento que efectivamente cambie.
  • Olvidar declarar tareas con type: Array: sin esa declaración explícita en static properties, Lit no sabría que tareas es una propiedad reactiva en absoluto, y ninguna asignación a this.tareas, mutada o no, dispararía jamás una actualización.

Ejercicios

  1. Añade a <task-list> un método gestionarTareaEliminada(idTarea), enlazado al evento tarea-eliminada del ejercicio 1 de la lección anterior, que elimine de this.tareas la tarea con ese id usando Array.prototype.filter (de forma inmutable, sin usar splice).
  2. Explica, basándote en el apartado 5, por qué this.tareas.sort((a, b) => a.prioridad - b.prioridad) no provocaría ninguna actualización visible en <task-list>, y reescribe esa línea de forma que sí lo haga.
  3. Un compañero propone simplificar gestionarTareaCambiada sustituyendo el Array.map por this.tareas = [...this.tareas]; this.tareas.find((t) => t.id === idTarea).estado = nuevoEstado;. Explica por qué esta variante, aunque cambia la referencia del array, sigue sin ser una solución correctamente inmutable, y qué problema concreto podría causar más adelante.

Soluciones

gestionarTareaEliminada(idTarea) {
  this.tareas = this.tareas.filter((tarea) => tarea.id !== idTarea);
}
@tarea-eliminada="${() => this.gestionarTareaEliminada(tarea.id)}"

filter devuelve siempre un array nuevo con los elementos que cumplen la condición, dejando fuera la tarea eliminada, sin mutar en ningún momento el array original.

  1. sort es uno de los métodos de array que mutan el array en el sitio y, además, lo devuelven a sí mismo como valor de retorno; this.tareas sigue siendo, después de la llamada, exactamente la misma referencia de array que antes (solo reordenada internamente), así que el setter de Lit no detecta ningún cambio y no dispara ninguna actualización, aunque el orden interno del array sí haya cambiado en memoria. La forma correcta es copiar primero el array y ordenar la copia: this.tareas = [...this.tareas].sort((a, b) => a.prioridad - b.prioridad);. Aquí [...this.tareas] crea primero un array nuevo (rompiendo la referencia anterior), y es sobre esa copia nueva sobre la que sort muta en el sitio; como la asignación final a this.tareas sí apunta a esa referencia nueva, Lit detecta el cambio correctamente.

  2. Aunque [...this.tareas] crea un array nuevo (por lo que Lit sí detectaría el cambio de referencia y dispararía una actualización), el operador de propagación de un array solo copia el propio array, no los objetos que contiene: los elementos de la copia siguen siendo las mismas referencias de objeto que en el array original. Por tanto, .find(...).estado = nuevoEstado sigue mutando en el sitio el mismo objeto tarea que también está referenciado desde el array anterior (si en algún otro lugar del código se hubiera guardado una referencia a ese array previo, por ejemplo para comparar "antes y después", ese objeto también aparecería ya modificado, aunque en teoría representara el estado "antiguo"). El problema concreto es sutil pero real: cualquier código que dependiera de que los objetos del array anterior permanecieran sin modificar (algo habitual, por ejemplo, en herramientas de depuración con historial de estados, o en comparaciones superficiales de objetos) se rompería silenciosamente. La solución correcta, como se explicó en el apartado 4, es crear también un objeto nuevo para el elemento afectado con { ...tarea, estado: nuevoEstado }, no solo un array nuevo.

Conclusión

Con esta lección se completa el ciclo real de comunicación entre <task-card> y <task-list>: un evento personalizado sube desde la tarjeta que detecta la interacción del usuario, y una propiedad actualizada baja desde la lista que decide cómo debe cambiar el estado compartido. La pieza que lo hace posible, más allá de la sintaxis de @evento y CustomEvent ya vistas, es la inmutabilidad: nunca mutar directamente un array o un objeto que vive en una propiedad reactiva, sino reemplazarlo siempre por una copia nueva con el cambio incorporado, para que la comparación por referencia de Lit pueda detectar que algo ha cambiado.

Este patrón resuelve la comunicación entre un padre y su hijo directo, pero TaskFlow está a punto de crecer con componentes que no tienen esa relación tan sencilla: en la siguiente lección, "Patrones de Comunicación entre Componentes Hermanos", se abordará qué hacer cuando dos componentes que necesitan compartir información —como la futura pareja <task-list> y <task-filter>— no son padre e hijo directo, sino hermanos bajo un mismo contenedor.

Curso de Lit

Módulo 1: Introducción a Lit y Web Components

Módulo 2: Plantillas Reactivas y Renderizado

Módulo 3: Propiedades y Estado Reactivo

Módulo 4: Estilos en Componentes Lit

Módulo 5: Eventos y Comunicación entre Componentes

Módulo 6: Ciclo de Vida y Comportamiento Avanzado

Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas

Módulo 8: Integración, Interoperabilidad y Despliegue

Módulo 9: Pruebas y Buenas Prácticas

Módulo 10: Proyecto: Construyendo TaskFlow

© Copyright 2026. Todos los derechos reservados