The previous lesson left <task-card> announcing, via the tarea-cambiada custom event, that the user has chosen a new state for a task. But an announcement with no one listening isn't of much use: this lesson closes the full cycle, making <task-list> receive that event, update its own tareas array, and have that updated array flow back down, as a property, to each <task-card>. Along the way, a concept appears that, while not exclusive to Lit, is decisive for reactivity to work properly: the immutability of the data stored in a reactive property.

Contents

  1. Reminder: from parent to child, properties
  2. Listening to a custom event with @event
  3. Updating the tasks array: the problem of mutating in place
  4. The immutable solution: creating a new array and object
  5. Why immutability matters for Lit's change detection
  6. The full cycle in <task-list>
  7. Closing: toward sibling components

  1. Reminder: from parent to child, properties

Module 3 already established the normal channel by which a parent component hands data down to a child component: reactive properties, assigned from the parent's template with the dot binding (.propiedad="${valor}") when the data isn't a simple string. This is exactly what <task-list> has already been doing since module 2 when rendering each card:

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

There is nothing new to explain here about the mechanism itself: it's still the same old property binding. What is new in this lesson is where the updated value of tarea.estado comes from that gets passed on every render: until now, this.tareas in <task-list> was a fixed array, initialized only once in the constructor and never modified afterward. Starting with this lesson, that array truly changes, in response to what happens inside each <task-card>.

  1. Listening to a custom event with @event

Lesson 05-01 introduced @event for native browser events (@click, @keydown); the good news is that the same syntax, with no changes at all, works exactly the same way with custom events like tarea-cambiada, because, as explained in the previous lesson, a CustomEvent is, for all purposes of the browser's event system, just another Event:

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>
  `;
}

Here a design detail appears that deserves an explanation: the handler is not directly this.gestionarTareaCambiada (as in the examples of lesson 05-01), but an inline arrow function, (event) => this.gestionarTareaCambiada(tarea.id, event). The reason is that gestionarTareaCambiada needs to know which specific task the change affects, and that information —the task's id— is only available inside the body of the Array.map, in the tarea variable of that particular iteration; the tarea-cambiada event itself, as dispatched in the previous lesson, only carries the new state in its detail, not any task identifier (because <task-card>, deliberately, doesn't know the concept of "id" or the structure of <task-list>'s array, as explained in section 1 of the previous lesson). The inline arrow function "captures" the value of tarea.id from that particular iteration and adds it as an extra argument when calling gestionarTareaCambiada, thus resolving, on the <task-list> side (which does know its own data structure), the correspondence between "which card emitted the event" and "which task in the array it corresponds to."

As already noted in lesson 05-01 regarding inline arrow functions inside templates, this has the small cost that Lit cannot reuse exactly the same listener between successive renders (each call to render() creates a new arrow function). For the typical number of cards in a task list, this cost is completely negligible, and the clarity of being able to capture tarea.id directly in the expression more than compensates for the alternative of trying to avoid it.

  1. Updating the tasks array: the problem of mutating in place

With the event now reaching gestionarTareaCambiada(idTarea, event), the most direct temptation is to find the corresponding task inside this.tareas and modify it in place:

// 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
}

At first glance this code looks reasonable, and it does "work" in the sense that the tarea object inside the array correctly changes its estado property in memory. The problem is not the mutation itself, but how Lit decides whether a reactive property has changed: as explained in module 3, changing this.tareas only triggers an update if Lit detects that the property's value differs from the previous one. And here's the trap: this.tareas remains, literally, the same array (the same reference in memory) before and after this mutation; only an object inside it has changed. Lit, for object- or array-typed properties, compares references, not deep content, so it detects no change at all, and no update fires automatically (hence the forced this.requestUpdate() in the example above, a patch that hides the real problem instead of solving it correctly).

  1. The immutable solution: creating a new array and object

The correct approach, and the one used from here on in TaskFlow, is to never mutate the array or the objects it contains, but instead create a new copy with the change already incorporated:

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

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

This version uses two standard JavaScript techniques, nothing Lit-specific: Array.prototype.map(), which always returns a new array (never modifies the original), and the object spread operator ({ ...tarea, estado: nuevoEstado }), which creates a new object by copying all of tarea's properties and then overwriting only estado with the value received in the event. For the tasks that don't match idTarea, map returns the same object reference they already had (no need to copy them, since they haven't changed); only the affected task gets a new object. The final result, this.tareas = ..., assigns to the reactive property an array reference completely different from the one it had before, even though the content of most of its elements is the same object as always.

  1. Why immutability matters for Lit's change detection

With the new assignment, this.tareas = this.tareas.map(...), the setter Lit generates for the tareas property (the same mechanism seen in module 3 for any reactive property) compares the previous reference of this.tareas with the new one: they are, literally, two different arrays in memory, so Lit's default comparison (!==, strict equality) detects the difference with no ambiguity, and schedules an update exactly as with any other property.

Operation on this.tareas Does the reference change? Does Lit detect the change?
this.tareas[0].estado = 'hecha' (direct mutation of an internal object) No No
this.tareas.push(nuevaTarea) (direct mutation of the array) No No
this.tareas.sort(...) (direct mutation of the array, even if it reorders) No No
this.tareas = this.tareas.map(...) Yes Yes
this.tareas = [...this.tareas, nuevaTarea] Yes Yes
this.tareas = this.tareas.filter(...) Yes Yes

This table summarizes a general rule that goes beyond this particular example and is worth internalizing for any object- or array-typed reactive property in Lit: array methods that modify in place (push, pop, splice, sort, reverse, or direct assignment to an index or a nested property) never change the reference of the containing array or object, so they never trigger an update on their own; methods that return a new copy (map, filter, concat, the spread operator [...array] or {...objeto}) do change the reference, and are the ones to use whenever Lit should react to the change. This isn't an arbitrary limitation of Lit: comparing an array's deep content on every update (instead of just its reference) would be far more computationally expensive for large data trees, so Lit, like many other reactive libraries, deliberately opts for fast, predictable reference comparison, in exchange for requiring this discipline of immutability in the code that uses it.

  1. The full cycle in <task-list>

With all the pieces explained, <task-list>'s complete code with the new handler looks like this:

// 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);

The full journey, end to end, is now as follows: the user changes a particular <task-card>'s <select>; <task-card> updates its own estado property (to reflect the change visually right away, without depending on getting confirmation back from its parent) and dispatches tarea-cambiada with the new state in detail; <task-list> receives the event through @tarea-cambiada, identifies, thanks to the closure over tarea.id, which task in the array it corresponds to, and replaces this.tareas with a new array, with a new object for that specific task and the rest untouched; Lit detects the reference change in this.tareas and re-runs render(); the template's Array.map iterates again over the updated array and passes, via .estado="${tarea.estado}", the new value down to each <task-card> (including the one that originated the change, which gets back, as a property, exactly the same value it already had from its own internal update). The cycle closes: an event went up, a property came down.

  1. Closing: toward sibling components

It's worth noting that this flow —child emits event, parent listens and updates state, updated state flows back down as a property— is exactly the same pattern, with no conceptual variation, that will repeat throughout the rest of the course every time a component needs to communicate a change upward: the event name will change, the detail will carry different data, the array-update logic will be different, but the underlying structure (custom event upward, reactive property downward, immutability in between) is always the same.

There remains, however, one scenario that this pattern, as laid out so far, does not directly solve: what happens when two components that need to communicate do not have a direct parent-child relationship, as will soon be the case between <task-list> and a future <task-filter>? That is exactly the topic of the next lesson.

Common Mistakes and Tips

  • Mutating the array or its objects in place and expecting Lit to react: as explained in section 3, this.tareas[0].estado = 'hecha' or this.tareas.push(...) do not change the reference of this.tareas, so Lit detects no change and the UI does not update, even though the data in memory has indeed changed.
  • Using this.requestUpdate() as a patch instead of fixing the mutation: it's possible to force a manual update with this.requestUpdate() after a direct mutation, and it "works" in the sense that the UI refreshes; but it's a symptom, not a solution, of mutating data that should be treated immutably, and tends to build up technical debt that's hard to track down later.
  • Copying only the outermost level when the change is deeper inside: if tarea had, for example, a nested object (tarea.metadatos.ultimaModificacion), copying only { ...tarea } would not be enough for a change inside metadatos to be reflected immutably; { ...tarea, metadatos: { ...tarea.metadatos, ultimaModificacion: ahora } } would also be needed, copying every level of nesting that actually changes.
  • Forgetting to declare tareas with type: Array: without that explicit declaration in static properties, Lit would not know that tareas is a reactive property at all, and no assignment to this.tareas, mutated or not, would ever trigger an update.

Exercises

  1. Add to <task-list> a gestionarTareaEliminada(idTarea) method, bound to the tarea-eliminada event from exercise 1 of the previous lesson, that removes from this.tareas the task with that id using Array.prototype.filter (immutably, without using splice).
  2. Explain, based on section 5, why this.tareas.sort((a, b) => a.prioridad - b.prioridad) would not cause any visible update in <task-list>, and rewrite that line so that it does.
  3. A teammate proposes simplifying gestionarTareaCambiada by replacing the Array.map with this.tareas = [...this.tareas]; this.tareas.find((t) => t.id === idTarea).estado = nuevoEstado;. Explain why this variant, although it changes the array's reference, is still not a correctly immutable solution, and what specific problem it could cause later on.

Solutions

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

filter always returns a new array with the elements that meet the condition, leaving out the deleted task, without ever mutating the original array.

  1. sort is one of the array methods that mutate the array in place and, additionally, return the array itself as its return value; this.tareas, after the call, remains exactly the same array reference as before (only internally reordered), so Lit's setter detects no change and triggers no update, even though the array's internal order has indeed changed in memory. The correct approach is to copy the array first and sort the copy: this.tareas = [...this.tareas].sort((a, b) => a.prioridad - b.prioridad);. Here [...this.tareas] first creates a new array (breaking the previous reference), and it's on that new copy that sort mutates in place; since the final assignment to this.tareas does point to that new reference, Lit correctly detects the change.

  2. Although [...this.tareas] creates a new array (so Lit would indeed detect the reference change and trigger an update), the spread operator on an array only copies the array itself, not the objects it contains: the elements of the copy remain the same object references as in the original array. Therefore, .find(...).estado = nuevoEstado still mutates in place the same tarea object that is also referenced from the previous array (if some other part of the code had kept a reference to that previous array, for example to compare "before and after," that object would also already appear modified, even though it was theoretically supposed to represent the "old" state). The specific problem is subtle but real: any code relying on the objects of the previous array remaining unmodified (something common, for example, in debugging tools with state history, or in shallow object comparisons) would break silently. The correct solution, as explained in section 4, is to also create a new object for the affected element with { ...tarea, estado: nuevoEstado }, not just a new array.

Conclusion

This lesson completes the real communication cycle between <task-card> and <task-list>: a custom event goes up from the card that detects the user's interaction, and an updated property comes down from the list that decides how the shared state should change. The piece that makes it possible, beyond the @event and CustomEvent syntax already seen, is immutability: never directly mutating an array or object living in a reactive property, but always replacing it with a new copy with the change incorporated, so that Lit's reference comparison can detect that something has changed.

This pattern solves communication between a parent and its direct child, but TaskFlow is about to grow with components that don't have such a simple relationship: in the next lesson, "Communication Patterns Between Sibling Components," we will tackle what to do when two components that need to share information —like the future pairing of <task-list> and <task-filter>— are not direct parent and child, but siblings under the same container.

Lit Course

Module 1: Introduction to Lit and Web Components

Module 2: Reactive Templates and Rendering

Module 3: Reactive Properties and State

Module 4: Styling Lit Components

Module 5: Events and Component Communication

Module 6: Lifecycle and Advanced Behavior

Module 7: Directives and Advanced Template Features

Module 8: Integration, Interoperability and Deployment

Module 9: Testing and Best Practices

Module 10: Project: Building TaskFlow

© Copyright 2026. All rights reserved