<task-card> sap, des del mòdul 3, reaccionar a un clic modificant el seu propi estat intern. Però una mirada honesta a TaskFlow deixa clar que això no n'hi ha prou: si l'usuari vol marcar una tasca com a "feta" des de la seva targeta, aquest canvi ha d'arribar d'alguna manera a <task-list>, que és qui manté l'array real de tasques. Aquesta lliçó resol aquest problema amb el mecanisme estàndard dels Web Components perquè un fill es comuniqui amb qui el conté: els esdeveniments personalitzats, construïts amb CustomEvent, despatxats amb dispatchEvent, i dissenyats per travessar netament la frontera del Shadow DOM. Al final de la lliçó, <task-card> tindrà un petit selector d'estat i emetrà un esdeveniment tarea-cambiada cada vegada que l'usuari l'utilitzi.

Contingut

  1. Per què un fill no ha de tocar l'estat del seu pare directament
  2. CustomEvent: un esdeveniment amb dades pròpies
  3. bubbles i composed: com viatja un esdeveniment a través del Shadow DOM
  4. Despatxar l'esdeveniment amb dispatchEvent
  5. Convenció de noms per a esdeveniments personalitzats
  6. Afegint un selector d'estat a <task-card>
  7. Emetent tarea-cambiada
  8. Tancament: qui escolta aquest esdeveniment

  1. Per què un fill no ha de tocar l'estat del seu pare directament

Imagina, per un moment, una solució temptadora però equivocada al problema plantejat a la introducció: que <task-card> rebi, com a propietat, una referència directa a l'array tareas de <task-list> (o fins i tot una referència al propi component <task-list>), i que en canviar l'estat d'una tasca, <task-card> modifiqui aquest array directament, cercant l'element que li correspon i mutant-lo en el lloc.

Aquest enfocament, encara que sembli estalviar codi, trenca una de les idees més importants del disseny de components ben encapsulats: un component fill no hauria de conèixer, i encara menys modificar, l'estructura interna de dades del seu pare. Si <task-card> hagués de saber que viu dins d'un array gestionat per <task-list>, deixaria de ser un component reutilitzable de forma aïllada: no es podria usar <task-card> solta en cap altra part de l'aplicació (una vista de detall d'una sola tasca, per exemple) sense arrossegar amb ella aquesta dependència cap a una estructura de dades que, en aquest altre context, ni tan sols existiria.

L'alternativa correcta, i la que segueix l'estàndard de Web Components des del seu origen, és justament la contrària quant a direcció de la responsabilitat: el fill no canvia res pel seu compte al pare; simplement anuncia que alguna cosa ha passat, sense saber ni necessitar saber qui l'està escoltant ni què en farà d'aquesta informació. És responsabilitat exclusiva de qui escolta (normalment el pare, encara que no necessàriament) decidir què fer amb l'avís: actualitzar el seu propi estat, ignorar-lo, o reenviar-lo al seu torn cap amunt. Aquest patró —el fill anuncia, el pare decideix— és exactament per a què existeixen els esdeveniments personalitzats.

  1. CustomEvent: un esdeveniment amb dades pròpies

El navegador permet crear esdeveniments propis, no natius, mitjançant el constructor CustomEvent, que és una subclasse de la classe Event estàndard (la mateixa família que els esdeveniments de clic o teclat ja vistos a la lliçó anterior). La seva diferència principal respecte a un esdeveniment natiu és que es pot adjuntar qualsevol dada personalitzada a través de la propietat detail:

const evento = new CustomEvent('tarea-cambiada', {
  detail: { id: 3, nuevoEstado: 'hecha' },
});

El primer argument del constructor és el nom de l'esdeveniment (una cadena de text triada lliurement, sobre la convenció de la qual es parlarà a l'apartat 5); el segon és un objecte d'opcions on detail pot contenir qualsevol valor de JavaScript: un objecte, un array, un número, fins i tot undefined si l'esdeveniment no necessita transportar cap dada addicional més enllà del fet d'haver-se produït. Qui escolti aquest esdeveniment accedirà a aquesta informació llegint event.detail, exactament igual que es llegeix qualsevol altra propietat de l'objecte esdeveniment.

  1. bubbles i composed: com viatja un esdeveniment a través del Shadow DOM

Crear un CustomEvent no n'hi ha prou per si sol perquè arribi on cal: per defecte, un esdeveniment no fa bombolla (no es propaga cap als elements ancestres) i, si l'element que el despatxa viu dins d'un shadow root, l'esdeveniment no surt d'aquesta frontera de Shadow DOM. Ambdós comportaments s'activen explícitament amb dues opcions del constructor:

const evento = new CustomEvent('tarea-cambiada', {
  detail: { id: 3, nuevoEstado: 'hecha' },
  bubbles: true,
  composed: true,
});
  • bubbles: true fa que l'esdeveniment, després de disparar-se a l'element origen, es propagui cap amunt a través de tots els seus ancestres a l'arbre del DOM, exactament el mateix mecanisme de bombolla esmentat a la lliçó anterior a propòsit de event.target. Sense aquesta opció, l'esdeveniment només el podria escoltar qui tingui un listener posat directament sobre l'element que el despatxa, la qual cosa és gairebé inútil a la pràctica: ningú fora del propi component té, ni hauria de tenir, una referència directa a l'element intern concret que va originar l'esdeveniment.
  • composed: true és l'opció específica de Web Components, i la que sol generar més dubtes: fa que l'esdeveniment pugui travessar la frontera del Shadow DOM, és a dir, sortir del shadow root on es va originar cap al DOM lleuger exterior. Recorda que, com es va explicar al mòdul 4, el Shadow DOM encapsula deliberadament el que passa dins d'un component; aquesta mateixa encapsulació, sense composed: true, aturaria la bombolla de l'esdeveniment just al límit del shadow root, i impediria que <task-list>, que viu fora del shadow root de <task-card>, arribés a assabentar-se de res.

La combinació d'ambdues opcions és, a la pràctica, la que s'utilitza gairebé universalment per a esdeveniments personalitzats que un component despatxa amb la intenció que el seu pare (o qualsevol ancestre) l'escolti: sense bubbles: true l'esdeveniment no puja; sense composed: true, encara que pugi, queda atrapat dins del propi shadow root del component que l'origina. Oblidar qualsevol de les dues és, amb diferència, l'error més freqüent en treballar amb esdeveniments personalitzats en Web Components, i es detalla a l'apartat d'errors comuns d'aquesta lliçó.

  1. Despatxar l'esdeveniment amb dispatchEvent

Un CustomEvent, un cop creat, no passa per si sol: cal despatxar-lo explícitament sobre un element del DOM, mitjançant el mètode dispatchEvent, heretat per tot element (inclosa qualsevol classe que estengui LitElement, que al seu torn estén HTMLElement) directament de la plataforma web:

class TaskCard extends LitElement {
  notificarCambioDeEstado(nuevoEstado) {
    this.dispatchEvent(
      new CustomEvent('tarea-cambiada', {
        detail: { nuevoEstado },
        bubbles: true,
        composed: true,
      })
    );
  }
}

this.dispatchEvent(...), cridat sobre la pròpia instància del component, fa que l'esdeveniment s'origini exactament a <task-card> (l'element personalitzat en si mateix, no un node intern del seu shadow root), i a partir d'aquí, gràcies a bubbles: true i composed: true, es propagui cap amunt a través del DOM lleuger exterior: primer cap al seu element pare immediat (normalment el <div class="lista"> de <task-list>), després cap a <task-list> mateix, i així successivament cap a qualsevol ancestre que tingui un listener posat per a tarea-cambiada.

Cal notar que dispatchEvent és una crida síncrona: tots els listeners que estiguin escoltant aquest esdeveniment en aquell moment s'executen immediatament, abans que dispatchEvent acabi d'executar-se i el codi continuï a la línia següent. Això no sol tenir implicacions pràctiques en l'ús habitual d'aquest curs, però convé saber-ho si mai cal raonar sobre l'ordre exacte en què s'executen diferents peces de codi.

  1. Convenció de noms per a esdeveniments personalitzats

El nom triat per a un esdeveniment personalitzat és una cadena de text lliure, però la comunitat de Web Components segueix, de forma gairebé universal, una convenció senzilla que convé respectar:

  • Minúscules i amb guions (kebab-case), igual que els noms dels propis elements personalitzats: tarea-cambiada, no tareaCambiada ni TareaCambiada. Els noms d'esdeveniments natius del navegador (click, mouseenter) no distingeixen majúscules de minúscules en el seu tractament intern, però per a esdeveniments personalitzats, kebab-case és la convenció dominant i la que segueix el propi Lit en la seva documentació i exemples.
  • Sense el prefix on: un error freqüent, sobretot en qui ve d'altres frameworks, és anomenar l'esdeveniment on-tarea-cambiada. El prefix on es reserva, per convenció del DOM, per als noms de les propietats manejadores (onclick, onchange), no per als noms dels esdeveniments en si mateixos; l'esdeveniment s'anomena click, no onclick, i de la mateixa manera l'esdeveniment personalitzat s'hauria d'anomenar tarea-cambiada, no on-tarea-cambiada.
  • Un nom que descrigui el fet ocorregut, no l'acció a realitzar: tarea-cambiada (una cosa que ja ha passat) és preferible a cambiar-tarea (una ordre). Això reforça la idea de l'apartat 1: el fill anuncia un fet consumat sobre si mateix, no dona una ordre al seu pare sobre què fer.

  1. Afegint un selector d'estat a <task-card>

Fins ara, <task-card> només permetia veure l'estat d'una tasca (amb renderInsigniaEstado()), no canviar-lo. Per poder disparar l'esdeveniment tarea-cambiada amb una dada real, cal primer una manera que l'usuari triï un nou estat des de la pròpia targeta: un <select> senzill, amb les tres opcions ja utilitzades a renderInsigniaEstado().

// src/components/task-card.js
class TaskCard extends LitElement {
  static properties = {
    titulo: { type: String },
    estado: { type: String },
    prioridad: { type: Number },
    urgente: { type: Boolean },
    expandida: { state: true },
    fechaLimite: { converter: conversorDeFecha, attribute: 'fecha-limite' },
  };

  // ...constructor sin cambios...

  gestionarCambioDeSelector(event) {
    // Atura la propagació del "change" natiu del <select>: l'esdeveniment
    // que interessa a qui utilitzi <task-card> no és "el select ha canviat",
    // sinó l'esdeveniment personalitzat propi que es despatxa just a sota.
    event.stopPropagation();

    const nuevoEstado = event.target.value;
    this.estado = nuevoEstado;
    this.notificarCambioDeEstado(nuevoEstado);
  }

  notificarCambioDeEstado(nuevoEstado) {
    this.dispatchEvent(
      new CustomEvent('tarea-cambiada', {
        detail: { nuevoEstado },
        bubbles: true,
        composed: true,
      })
    );
  }

  renderSelectorEstado() {
    return html`
      <select @change="${this.gestionarCambioDeSelector}" .value="${this.estado}">
        <option value="pendiente">Pendiente</option>
        <option value="en-progreso">En progreso</option>
        <option value="hecha">Hecha</option>
      </select>
    `;
  }

  render() {
    return html`
      <article @click="${this.alternarExpandida}">
        <h3>${this.titulo}</h3>
        ${this.renderInsigniaEstado()}
        ${this.renderSelectorEstado()}
        <p>Prioridad: ${this.prioridad}</p>
        ${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
        ${this.expandida
          ? html`<div class="detalle"><p>Estado interno: la tarjeta está expandida.</p></div>`
          : ''}
      </article>
    `;
  }
}

Dos detalls mereixen atenció abans de passar a l'esdeveniment en si mateix. Primer, .value="${this.estado}" utilitza el binding de propietat amb punt, ja vist en mòduls anteriors, en lloc d'un atribut value normal: això assegura que el <select> mostri sempre l'opció corresponent al valor actual de this.estado, encara que aquest valor canviï per una via diferent al propi selector (per exemple, si en el futur <task-list> tornés a baixar una propietat estado actualitzada). Segon, event.stopPropagation() dins de gestionarCambioDeSelector evita que l'esdeveniment natiu change del <select> continuï fent bombolla cap amunt: no tindria sentit que <task-list> (o qualsevol altre ancestre) hagués de distingir entre el change genèric d'un <select> intern de <task-card> i qualsevol altre change que pogués produir-se en un altre lloc de la interfície; l'esdeveniment realment rellevant cap a fora és l'esdeveniment personalitzat tarea-cambiada, no l'esdeveniment natiu que l'origina internament.

  1. Emetent tarea-cambiada

El flux complet, de principi a fi, queda així: l'usuari obre el <select> d'una targeta i tria "Hecha"; el navegador dispara un esdeveniment natiu change sobre el <select>; gestionarCambioDeSelector el captura, atura la seva propagació, actualitza this.estado (el qual dispara, com qualsevol propietat reactiva, un nou renderitzat de la pròpia targeta, ara mostrant la insígnia corresponent al nou estat) i crida notificarCambioDeEstado('hecha'); aquest mètode crea i despatxa un CustomEvent anomenat tarea-cambiada, amb detail: { nuevoEstado: 'hecha' }, configurat amb bubbles: true i composed: true perquè pugui sortir del shadow root de <task-card> i arribar fins a qui estigui escoltant a fora, típicament <task-list>.

És important notar que, en aquest punt de la lliçó, <task-card> ja ha fet tot el que li correspon: ha actualitzat la seva pròpia aparença (mitjançant this.estado) i ha anunciat el canvi cap a fora (mitjançant l'esdeveniment). No sap, ni li importa, si algú està escoltant aquest esdeveniment, ni què farà aquesta escolta amb la informació rebuda. Aquesta responsabilitat, deliberadament, es trasllada a la lliçó següent, on <task-list> escoltarà tarea-cambiada i decidirà què fer-ne.

  1. Tancament: qui escolta aquest esdeveniment

Per comprovar, sense avançar encara el contingut complet de la lliçó següent, que l'esdeveniment realment es despatxa i arriba fins fora del component, n'hi ha prou amb un listener mínim col·locat directament en HTML, fora de qualsevol shadow root:

<task-card titulo="Revisar el PR de autenticación" estado="pendiente"></task-card>

<script>
  document.querySelector('task-card').addEventListener('tarea-cambiada', (event) => {
    console.log('Nuevo estado recibido en el padre:', event.detail.nuevoEstado);
  });
</script>

Aquest listener, afegit amb addEventListener normal sobre la instància de l'element (exactament el mateix mètode vist a la lliçó anterior per a esdeveniments natius, perquè, com es va explicar a l'apartat 2, un CustomEvent és, en el fons, un Event com qualsevol altre), confirma que l'esdeveniment surt del Shadow DOM de <task-card> i es pot escoltar des de fora amb les eines més bàsiques del DOM, sense cap necessitat que qui escolti sigui un altre component de Lit. A la pràctica de TaskFlow, però, qui escoltarà aquest esdeveniment no serà un script solt sinó <task-list>, mitjançant la mateixa sintaxi declarativa @evento ja coneguda, aplicada aquesta vegada a un esdeveniment personalitzat en lloc d'un natiu del navegador.

Errors Comuns i Consells

  • Oblidar composed: true: és, amb diferència, l'error més freqüent en depurar "el meu esdeveniment personalitzat no arriba al meu pare". Si el component que despatxa l'esdeveniment té Shadow DOM (com qualsevol LitElement), i l'esdeveniment no porta composed: true, la bombolla s'atura exactament al límit del shadow root, i ningú fora d'aquest límit el rebrà mai, per molt que tingui posat un listener aparentment correcte.
  • Oblidar bubbles: true: sense aquesta opció, l'esdeveniment no es propaga en absolut cap amunt; només el rebria un listener posat directament sobre l'element exacte que el despatxa, la qual cosa, a la pràctica de comunicació entre components, gairebé mai és útil.
  • Anomenar l'esdeveniment amb el prefix on: com es va explicar a l'apartat 5, on-tarea-cambiada trenca la convenció estàndard; el nom de l'esdeveniment descriu el fet ocorregut (tarea-cambiada), mai porta el prefix reservat per a les propietats manejadores del DOM.
  • Ficar massa lògica de decisió dins del component que despatxa l'esdeveniment: <task-card> es limita a anunciar que l'usuari ha triat un nou estat; no li correspon a <task-card> decidir, per exemple, si aquest canvi d'estat és vàlid segons alguna regla de negoci més àmplia (com impedir marcar una tasca com a "feta" si té subtasques pendents). Aquestes decisions són responsabilitat de qui escolta l'esdeveniment, no de qui l'emet.
  • Oblidar event.stopPropagation() en l'esdeveniment natiu que origina el personalitzat: si gestionarCambioDeSelector no atura la propagació del change natiu del <select>, ambdós esdeveniments (el change natiu i el tarea-cambiada personalitzat) fan bombolla cap a fora, i qualsevol ancestre que per casualitat escolti change de forma genèrica (poc habitual, però possible) rebria un esdeveniment que no li correspon interpretar.

Exercicis

  1. Afegeix a <task-card> un botó "Eliminar tarea" que despatxi un nou esdeveniment personalitzat tarea-eliminada, amb detail buit (no cal transportar cap dada, ja que qui escolti ja sap sobre quina instància concreta de <task-card> s'ha produït l'esdeveniment), configurat amb bubbles: true i composed: true.
  2. Explica, basant-te en l'apartat 3, què passaria si <task-card> despatxés tarea-cambiada amb bubbles: true però sense composed: true, en el cas concret que <task-list> (que viu fora del shadow root de cada <task-card>) tingués un listener @tarea-cambiada posat sobre cada targeta.
  3. Un company d'equip proposa que, en lloc de despatxar un esdeveniment, <task-card> rebi una funció de callback com a propietat (per exemple, .onCambioDeEstado="${miFuncion}") i la cridi directament quan l'usuari canviï l'estat. Compara aquesta alternativa amb el patró d'esdeveniments personalitzats vist en aquesta lliçó, assenyalant almenys un avantatge dels esdeveniments personalitzats davant d'aquesta alternativa.

Solucions

notificarEliminacion() {
  this.dispatchEvent(
    new CustomEvent('tarea-eliminada', {
      bubbles: true,
      composed: true,
    })
  );
}

render() {
  return html`
    <article @click="${this.alternarExpandida}">
      ...
      <button @click="${(event) => { event.stopPropagation(); this.notificarEliminacion(); }}">
        Eliminar tarea
      </button>
    </article>
  `;
}

Cal notar que aquí també fa falta event.stopPropagation() dins del manejador del botó: sense ella, el clic sobre "Eliminar tarea" faria bombolla igualment fins a l'<article> i dispararia a més alternarExpandida, expandint o contraient la targeta just quan l'usuari només volia eliminar-la.

  1. Sense composed: true, l'esdeveniment tarea-cambiada, encara que tingui bubbles: true, quedaria atrapat dins del shadow root de la pròpia <task-card> que el despatxa i mai arribaria a travessar aquesta frontera cap al DOM lleuger exterior, on viu <task-list>. El listener @tarea-cambiada posat per <task-list> sobre cada <task-card>, encara que estigui sintàcticament ben escrit, simplement no es dispararia mai: l'esdeveniment existiria i faria bombolla, però només dins d'un arbre (el shadow root de <task-card>) que <task-list> no pot veure.

  2. El patró de callback com a propietat obligaria <task-list> a passar una funció concreta a cada <task-card>, i aquest enllaç seria exclusiu entre aquestes dues instàncies concretes: qualsevol altre codi que volgués assabentar-se també del canvi d'estat (per exemple, un component d'estadístiques que s'afegís més endavant a TaskFlow) hauria de modificar <task-card> per acceptar una segona funció de callback, o encadenar crides manualment. Amb el patró d'esdeveniments, en canvi, qualsevol nombre de listeners pot escoltar tarea-cambiada de forma independent, sense que <task-card> necessiti saber quants són ni modificar res per admetre'n un més; és el mateix mecanisme estàndard que ja permet que un botó natiu tingui diversos addEventListener('click', ...) simultanis sense conflicte entre ells.

Conclusió

En aquesta lliçó s'ha resolt el problema central de la comunicació de fill a pare en Web Components: per què un component fill no ha de tocar directament l'estat del seu pare, com construir un CustomEvent amb dades pròpies a detail, per què fan falta tant bubbles: true com composed: true perquè l'esdeveniment travessi la frontera del Shadow DOM, com despatxar-lo amb dispatchEvent, i la convenció de noms en minúscules i guions, sense el prefix on. <task-card> ja té, com a resultat, un selector d'estat real que emet tarea-cambiada cada vegada que l'usuari tria un nou valor.

Queda, però, una peça pendent: <task-card> anuncia el canvi, però ningú l'està recollint encara de forma útil. A la lliçó següent, "Comunicació de Pare a Fill amb Propietats", <task-list> escoltarà tarea-cambiada amb @tarea-cambiada, actualitzarà el seu propi array tareas de forma immutable, i aquest array actualitzat tornarà a baixar com a propietat cap a totes les targetes, tancant així el cicle complet de comunicació entre <task-card> i <task-list>.

Curs de Lit

Mòdul 1: Introducció a Lit i Web Components

Mòdul 2: Plantilles Reactives i Renderitzat

Mòdul 3: Propietats i Estat Reactiu

Mòdul 4: Estils en Components Lit

Mòdul 5: Esdeveniments i Comunicació entre Components

Mòdul 6: Cicle de Vida i Comportament Avançat

Mòdul 7: Directives i Funcionalitats Avançades de Plantilles

Mòdul 8: Integració, Interoperabilitat i Desplegament

Mòdul 9: Proves i Bones Pràctiques

Mòdul 10: Projecte: Construint TaskFlow

© Copyright 2026. Tots els drets reservats