La lección anterior terminó con un problema honesto: conectarAcciones registra un manejador por cada botón de cada tarea, y en cuanto empieces a crear los <li> desde JavaScript, los elementos nuevos nacerán sin manejador. La solución no es reconectar todo cada vez, sino entender que un evento no ocurre solo en un elemento: viaja por el árbol del DOM, de la raíz hasta el objetivo y de vuelta. Aprovechando ese viaje se puede poner un único manejador en el contenedor que atienda a todos sus hijos, presentes y futuros. En esta lección aprenderás las tres fases de ese recorrido, las tres formas de interrumpirlo (y en qué se diferencian), el patrón de la delegación —probablemente la técnica más rentable de todo el módulo— y los eventos personalizados, que te permitirán que la vista y el modelo se comuniquen sin conocerse.

Contenido

  1. El viaje de un evento: captura, objetivo y burbujeo
  2. Una demostración en tres niveles
  3. Escuchar en la fase de captura
  4. Detener el viaje: stopPropagation y stopImmediatePropagation
  5. preventDefault no es lo mismo (tabla comparativa)
  6. Eventos que no burbujean, y sus equivalentes que sí
  7. Delegación de eventos
  8. event.target.closest() y dataset: el patrón completo
  9. Nómada Tareas: el tablero con un único manejador
  10. Eventos personalizados: CustomEvent y detail
  11. Desacoplar la vista del modelo con eventos
  12. AbortController: retirar muchos manejadores de golpe
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. El viaje de un evento: captura, objetivo y burbujeo

Cuando haces clic en el botón de una tarea, ese clic no ocurre «en el botón» y ya está. Ocurre en el botón, que está dentro de un <li>, que está dentro de un <ul>, que está dentro de una <section>… El navegador considera que el evento le concierne a toda esa cadena, y la recorre en tres fases:

  1. Captura (capturing): el evento desciende desde window hasta el elemento objetivo, pasando por todos sus antepasados.
  2. Objetivo (target): el evento llega al elemento donde ocurrió realmente.
  3. Burbujeo (bubbling): el evento asciende desde el objetivo hasta window, otra vez por todos los antepasados.
flowchart TD
    W1["window"] --> D1["document"]
    D1 --> S1["section.tablero"]
    S1 --> U1["ul#lista-tareas"]
    U1 --> L1["li[data-id=6]"]
    L1 --> B["button.tarea__accion<br/>◀ FASE 2 · OBJETIVO"]
    B --> L2["li[data-id=6]"]
    L2 --> U2["ul#lista-tareas"]
    U2 --> S2["section.tablero"]
    S2 --> D2["document"]
    D2 --> W2["window"]

    W1 -.- CAP["FASE 1 · CAPTURA<br/>de fuera hacia dentro"]
    W2 -.- BUR["FASE 3 · BURBUJEO<br/>de dentro hacia fuera"]

Por defecto, addEventListener registra el manejador en la fase de burbujeo. Por eso, en la lección anterior, un manejador puesto en el <li> se disparaba al hacer clic en el botón que contiene: el evento nació en el botón y burbujeó hasta el <li>.

Dos propiedades del objeto Event describen este viaje:

  • event.eventPhase: 1 en captura, 2 en el objetivo, 3 en burbujeo.
  • event.composedPath(): el array completo de nodos por los que pasa, del objetivo hacia arriba.
boton.addEventListener('click', (e) => {
  console.log(e.composedPath().map((n) => n.nodeName ?? n.constructor.name));
  // ['BUTTON', 'LI', 'UL', 'SECTION', 'MAIN', 'BODY', 'HTML', '#document', 'Window']
});

  1. Una demostración en tres niveles

La mejor forma de interiorizar esto es verlo. Registra un manejador en cada nivel y haz un solo clic en el botón:

const section = document.querySelector('.tablero');
const ul      = document.querySelector('#lista-tareas');
const li      = document.querySelector('[data-id="6"]');
const boton   = li.querySelector('.tarea__accion');

function espiar(nombre) {
  return (evento) => {
    const fases = { 1: 'CAPTURA', 2: 'OBJETIVO', 3: 'BURBUJEO' };
    console.log(`${fases[evento.eventPhase]} · ${nombre} · target=${evento.target.tagName}`);
  };
}

section.addEventListener('click', espiar('section'));
ul.addEventListener('click',      espiar('ul'));
li.addEventListener('click',      espiar('li'));
boton.addEventListener('click',   espiar('button'));

Un clic sobre el botón imprime:

OBJETIVO · button · target=BUTTON
BURBUJEO · li      · target=BUTTON
BURBUJEO · ul      · target=BUTTON
BURBUJEO · section · target=BUTTON

Cuatro manejadores ejecutados con un solo clic. Observaciones fundamentales:

  • target vale BUTTON en los cuatro. El objetivo no cambia durante el viaje: es siempre donde ocurrió el evento. Lo que cambia es currentTarget, que en cada línea es el elemento donde registraste ese manejador concreto.
  • No aparece ninguna línea de CAPTURA, porque los cuatro manejadores se registraron en burbujeo (el valor por defecto).
  • El orden es de dentro hacia fuera. Primero el más profundo, luego sus antepasados.

Esta es exactamente la propiedad que hace posible la delegación: un manejador en el <ul> ya está recibiendo los clics de todos sus descendientes.

  1. Escuchar en la fase de captura

Con { capture: true } el manejador se registra en la fase descendente:

section.addEventListener('click', espiar('section CAPTURA'), { capture: true });
ul.addEventListener('click',      espiar('ul CAPTURA'),      { capture: true });
li.addEventListener('click',      espiar('li'));               // burbujeo
boton.addEventListener('click',   espiar('button'));

Ahora el mismo clic imprime:

CAPTURA  · section CAPTURA · target=BUTTON
CAPTURA  · ul CAPTURA      · target=BUTTON
OBJETIVO · button          · target=BUTTON
BURBUJEO · li              · target=BUTTON

La captura va de fuera hacia dentro y siempre ocurre antes que el burbujeo. Existe también una forma abreviada heredada, addEventListener('click', fn, true), con el tercer parámetro booleano; hoy se prefiere el objeto de opciones por legibilidad.

¿Cuándo se usa la captura? Rara vez, y siempre con un motivo concreto:

  • Interceptar antes que nadie, por ejemplo para registrar analítica o para bloquear la interacción con toda una zona de la interfaz mientras se guarda algo.
  • Capturar eventos que no burbujean, como focus o error en imágenes, desde un contenedor. Es el truco del apartado 6.

En el 95 % de los casos, el burbujeo es lo que quieres.

  1. Detener el viaje: stopPropagation y stopImmediatePropagation

evento.stopPropagation() detiene el recorrido: los manejadores que quedaban por delante en el árbol no se ejecutan.

li.addEventListener('click', (evento) => {
  evento.stopPropagation();
  console.log('el li corta aquí');
});

// Un clic en el botón imprime:
// OBJETIVO · button
// el li corta aquí
// … y NADA más: 'ul' y 'section' ya no se enteran.

evento.stopImmediatePropagation() es más contundente: además de detener el viaje hacia arriba, impide que se ejecuten los demás manejadores del mismo elemento.

li.addEventListener('click', (e) => { e.stopImmediatePropagation(); console.log('primero'); });
li.addEventListener('click', () => console.log('segundo'));   // ✗ nunca se ejecuta

// Con stopPropagation() en lugar de stopImmediatePropagation(),
// 'segundo' SÍ se ejecutaría, y solo se cortaría la subida hacia el <ul>.

Aviso importante: stopPropagation es peligroso. Es una decisión que tomas en un componente y que afecta a todo el resto de la aplicación, incluida la parte que todavía no has escrito. Escenario típico: pones stopPropagation en una tarjeta para que un clic no active algo del contenedor; tres semanas después alguien añade un manejador en document para cerrar un menú desplegable al hacer clic fuera, y ese menú deja de cerrarse encima de tus tarjetas. El fallo es imposible de encontrar porque no hay ningún error.

La alternativa casi siempre es mejor: que el manejador de arriba compruebe si el evento le concierne.

// ✗ El hijo impone su criterio a todos
boton.addEventListener('click', (e) => e.stopPropagation());

// ✓ El padre decide con una guarda
ul.addEventListener('click', (evento) => {
  if (evento.target.closest('.tarea__accion') === null) return;   // no me concierne
  // …
});

  1. preventDefault no es lo mismo (tabla comparativa)

Estos tres métodos se confunden constantemente, y hacen cosas completamente distintas:

Método Qué hace Qué NO hace
preventDefault() Cancela la acción por defecto del navegador (seguir un enlace, enviar un formulario, desplazar con la barra espaciadora) No detiene la propagación: el evento sigue subiendo
stopPropagation() Impide que el evento siga hacia los siguientes elementos del recorrido No cancela la acción por defecto; no impide los otros manejadores del mismo elemento
stopImmediatePropagation() Lo anterior y además impide los demás manejadores del mismo elemento No cancela la acción por defecto

Corolario práctico:

enlace.addEventListener('click', (evento) => {
  evento.stopPropagation();
  // El navegador SIGUE navegando: falta preventDefault()
});

enlace.addEventListener('click', (evento) => {
  evento.preventDefault();
  // El evento SIGUE burbujeando hasta document: es lo normal y casi siempre deseable
});

Un detalle útil: cuando un manejador ha llamado a preventDefault(), los manejadores posteriores del recorrido pueden detectarlo con evento.defaultPrevented, lo que permite escribir código cooperativo en lugar de código que se pisa.

  1. Eventos que no burbujean, y sus equivalentes que sí

No todos los eventos burbujean. Los más importantes que no lo hacen:

Evento que no burbujea Equivalente que sí burbujea Nota
focus focusin Se dispara antes de que el elemento reciba el foco
blur focusout Se dispara antes de perderlo
mouseenter mouseover Recuerda que mouseover se dispara también con los hijos
mouseleave mouseout Igual
load, error (en <img>, <script>) Solo se pueden capturar con { capture: true }

Consecuencia directa: no puedes delegar focus ni blur, porque nunca llegan al contenedor.

// ✗ No funciona: 'focus' no burbujea
formulario.addEventListener('focus', (e) => console.log('foco en', e.target.name));

// ✓ Opción A: usar el equivalente que sí burbujea
formulario.addEventListener('focusin', (e) => console.log('foco en', e.target.name));

// ✓ Opción B: escuchar en la fase de captura
formulario.addEventListener('focus', (e) => console.log('foco en', e.target.name), { capture: true });

La opción A es la recomendable: es más clara y no obliga a razonar sobre fases.

Para load y error de imágenes solo queda la captura, y resulta muy práctica para detectar de golpe cualquier imagen rota de la página:

document.addEventListener('error', (evento) => {
  if (evento.target.tagName === 'IMG') {
    evento.target.classList.add('imagen-rota');
  }
}, { capture: true });

  1. Delegación de eventos

La delegación consiste en registrar un solo manejador en un antepasado común y decidir dentro qué hacer según de dónde venga el evento. Aprovecha el burbujeo del apartado 1.

flowchart TD
    subgraph SIN["Sin delegación · 6 manejadores"]
        U1["ul#lista-tareas"] --> A1["li 1 → manejador"]
        U1 --> A2["li 2 → manejador"]
        U1 --> A3["li 3 → manejador"]
        U1 --> A4["li 4 → manejador"]
        U1 --> A5["li 5 → manejador"]
        U1 --> A6["li 6 → manejador"]
    end
    subgraph CON["Con delegación · 1 manejador"]
        U2["ul#lista-tareas<br/>★ único manejador"] --> B1["li 1"]
        U2 --> B2["li 2"]
        U2 --> B3["li 3"]
        U2 --> B4["li 4"]
        U2 --> B5["li 5"]
        U2 --> B6["li 6"]
    end

Las tres ventajas, por orden de importancia:

  1. Funciona con elementos que todavía no existen. Es la razón principal. Cuando en 06-05 crees un <li> nuevo y lo insertes en el <ul>, sus clics burbujearán hasta el manejador que ya estaba puesto. No hay que reconectar nada, y por tanto no hay riesgo de duplicar manejadores ni de olvidarse de uno.
  2. Menos memoria y menos trabajo de registro. Un manejador en lugar de doscientos. Con seis tareas la diferencia es irrelevante; con una lista larga, no. La cuestión se trata a fondo en Gestión de Memoria y Manipulación Eficiente del DOM.
  3. Un único punto donde leer la lógica de interacción. Toda la respuesta a los clics de la lista está en una función, no repartida por seis sitios.

También tiene inconvenientes que conviene conocer: el manejador recibe todos los clics de la zona, así que necesita una guarda clara al principio; y no sirve para eventos que no burbujean (apartado 6).

  1. event.target.closest() y dataset: el patrón completo

Un manejador delegado siempre tiene la misma forma, y merece la pena memorizarla:

ul.addEventListener('click', (evento) => {
  // 1 · ¿El clic viene de un elemento que me interesa?
  const boton = evento.target.closest('[data-accion]');
  if (boton === null) return;                        // guarda: no me concierne

  // 2 · ¿Está dentro de mi contenedor? (protege de casos raros)
  if (!ul.contains(boton)) return;

  // 3 · ¿A qué tarea pertenece?
  const li = boton.closest('li[data-id]');
  const id = Number(li.dataset.id);                  // ¡conversión explícita!

  // 4 · ¿Qué acción hay que ejecutar?
  const accion = boton.dataset.accion;               // 'avanzar' | 'eliminar' | …

  console.log({ id, accion });
});

Cada paso tiene su porqué:

  • evento.target es el elemento más profundo, y ese es el problema que resuelve closest(). Si tu botón contiene un <span> con un icono, el target de un clic sobre el icono será el <span>, no el botón. Comprobar evento.target.matches('[data-accion]') fallaría en ese caso; closest() sube hasta encontrar el botón y funciona siempre.
  • La guarda === null es obligatoria. Un clic en el hueco entre dos tareas también llega a tu manejador, y sin la guarda tendrías un TypeError.
  • dataset.accion convierte el manejador en un despachador genérico: no hay una rama por botón, sino un dato en el HTML que dice qué hacer. Es el diccionario de consulta de 02-03 aplicado a la interfaz.
  • Number(li.dataset.id) es la conversión de frontera de 06-02: la página habla en strings, el modelo en números.

  1. Nómada Tareas: el tablero con un único manejador

Rehacemos el controlador de la lección anterior. Ahora el HTML incluye dos acciones por tarea y usa data-accion:

<ul id="lista-tareas" class="lista-tareas">
  <li class="tarea" data-id="1">
    <span class="tarea__titulo"></span>
    <span class="tarea__meta"></span>
    <button type="button" class="tarea__accion" data-accion="avanzar"></button>
    <button type="button" class="tarea__accion" data-accion="reabrir">Reabrir</button>
  </li>
  <!-- … el resto de tareas, con la misma estructura … -->
</ul>

Y el controlador completo, con un solo addEventListener:

// js/vista/controlador.js
import { HOY } from '../util/fechas.js';
import { pintarTarea, pintarResumen } from './pintar.js';

const SIGUIENTE = Object.freeze({ pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null });
const ETIQUETA  = Object.freeze({ pendiente: 'Empezar', 'en-curso': 'Marcar hecha', hecha: 'Completada' });

/** Traduce una acción de la interfaz al estado destino del modelo. */
function estadoDestino(accion, tarea) {
  if (accion === 'avanzar') return SIGUIENTE[tarea.estado];
  if (accion === 'reabrir') return tarea.estado === 'en-curso' ? 'pendiente' : null;
  return null;
}

export function conectarTablero({ lista, resumen, tablero, hoy = HOY, signal }) {
  /** Refresca un <li> completo: textos, clases y botones. */
  function refrescar(li, tarea) {
    pintarTarea(li, tarea, hoy);
    const avanzar = li.querySelector('[data-accion="avanzar"]');
    avanzar.textContent = ETIQUETA[tarea.estado];
    avanzar.disabled = SIGUIENTE[tarea.estado] === null;
    avanzar.setAttribute('aria-label', `${ETIQUETA[tarea.estado]}: ${tarea.titulo}`);

    const reabrir = li.querySelector('[data-accion="reabrir"]');
    reabrir.disabled = tarea.estado !== 'en-curso';
    reabrir.setAttribute('aria-label', `Devolver a pendiente: ${tarea.titulo}`);
  }

  // ── UN ÚNICO MANEJADOR PARA TODA LA LISTA ────────────────────────────────
  lista.addEventListener('click', (evento) => {
    const boton = evento.target.closest('button[data-accion]');
    if (boton === null || !lista.contains(boton)) return;      // no me concierne

    const li = boton.closest('li[data-id]');
    const tarea = tablero.buscarPorId(Number(li.dataset.id));
    if (tarea === null) return;

    const destino = estadoDestino(boton.dataset.accion, tarea);
    if (destino === null) return;

    try {
      tablero.cambiarEstado(tarea.id, destino);       // R6: el modelo valida la transición
    } catch (error) {
      console.error(error.describir?.() ?? error.message);
      return;
    }

    refrescar(li, tarea);
    pintarResumen(resumen, tablero, hoy);

    // Avisamos al resto de la aplicación (apartado 11)
    li.dispatchEvent(new CustomEvent('tarea:cambiada', {
      bubbles: true,
      detail: { id: tarea.id, estado: tarea.estado, titulo: tarea.titulo }
    }));
  }, { signal });

  // Pintado inicial
  for (const li of lista.querySelectorAll('li[data-id]')) {
    const tarea = tablero.buscarPorId(Number(li.dataset.id));
    if (tarea !== null) refrescar(li, tarea);
  }
  pintarResumen(resumen, tablero, hoy);
}

Compara con la versión de 06-03: allí había un addEventListener dentro de un bucle; aquí hay uno solo, fuera de todo bucle. Y lo importante es lo que no hay que cambiar cuando en la lección siguiente los <li> se creen desde JavaScript: nada. El manejador ya está puesto en el <ul>, y cualquier botón que aparezca dentro le llegará por burbujeo.

  1. Eventos personalizados: CustomEvent y detail

Hasta ahora todos los eventos venían del navegador. Pero tú también puedes crear y disparar los tuyos, y eso convierte al DOM en un canal de comunicación entre las partes de tu aplicación.

// Crear
const evento = new CustomEvent('tarea:cambiada', {
  detail: { id: 6, estado: 'en-curso', titulo: 'Presupuesto de la carpintería' },
  bubbles: true,      // ¿burbujea? Por defecto false
  cancelable: true    // ¿se puede llamar a preventDefault()? Por defecto false
});

// Disparar sobre un elemento (o sobre document/window)
li.dispatchEvent(evento);

// Escuchar, en cualquier antepasado si burbujea
document.addEventListener('tarea:cambiada', (e) => {
  console.log(`La tarea ${e.detail.id} pasó a ${e.detail.estado}`);
});

Cuatro puntos importantes:

  • detail es el único sitio donde van tus datos. Es una propiedad de solo lectura y puede contener cualquier valor: un objeto, un array, un número.
  • bubbles es false por defecto, al revés que la mayoría de eventos nativos. Si lo olvidas, el manejador de document no se entera nunca y perderás un buen rato buscando el fallo.
  • dispatchEvent es síncrono. No encola nada: ejecuta los manejadores inmediatamente y devuelve el control cuando terminan. Es distinto del comportamiento de los eventos del usuario, que sí pasan por la cola de tareas (05-07).
  • La convención de nombres dominio:accion (tarea:cambiada, tablero:actualizado, filtro:aplicado) evita colisiones con eventos nativos presentes y futuros, y hace evidente en el código de qué se está hablando.

Si el evento es cancelable, dispatchEvent devuelve false cuando algún manejador llamó a preventDefault(). Eso permite que un evento actúe como una petición de permiso:

const seguir = li.dispatchEvent(new CustomEvent('tarea:antes-de-cerrar', {
  bubbles: true, cancelable: true, detail: { id: tarea.id }
}));

if (!seguir) {
  console.log('Alguien ha vetado el cierre de la tarea.');
  return;
}
tablero.cambiarEstado(tarea.id, 'hecha');

  1. Desacoplar la vista del modelo con eventos

Este es el uso valioso. Sin eventos personalizados, cada parte de la interfaz que deba reaccionar a un cambio tiene que ser conocida por quien lo provoca:

// ✗ El controlador tiene que conocer a todo el mundo
tablero.cambiarEstado(id, destino);
refrescarLista();
refrescarResumen();
refrescarGraficoDeCarga();
refrescarContadorDeVencidas();
// … y cada nueva pieza obliga a tocar esta función

Con eventos, quien provoca el cambio solo anuncia que ha ocurrido, y quien esté interesado se suscribe:

flowchart LR
    U["Clic de Marta"] --> C["controlador.js<br/>delegado en el ul"]
    C --> M["modelo<br/>tablero.cambiarEstado()"]
    M --> C
    C -- "dispatchEvent<br/>tarea:cambiada" --> DOC["document"]
    DOC --> V1["Lista de tareas<br/>repinta el li"]
    DOC --> V2["Resumen<br/>recalcula horas"]
    DOC --> V3["Registro de actividad<br/>añade una línea"]

El controlador no sabe cuántos oyentes hay ni qué hacen. Añadir un cuarto no le obliga a cambiar ni una línea. En Nómada Tareas:

// js/vista/eventos.js — nombres centralizados, para no escribir cadenas sueltas
export const EVENTOS = Object.freeze({
  TAREA_CAMBIADA:     'tarea:cambiada',
  TAREA_CREADA:       'tarea:creada',
  TABLERO_ACTUALIZADO:'tablero:actualizado',
  FILTRO_APLICADO:    'filtro:aplicado'
});

/** Dispara un evento de la aplicación desde un elemento (burbujea siempre). */
export function emitir(origen, tipo, detail = {}) {
  return origen.dispatchEvent(new CustomEvent(tipo, { bubbles: true, detail }));
}
// js/vista/registro.js — una vista nueva que nadie tuvo que "conectar"
import { EVENTOS } from './eventos.js';

export function conectarRegistro(contenedor, { signal } = {}) {
  document.addEventListener(EVENTOS.TAREA_CAMBIADA, (evento) => {
    const { titulo, estado } = evento.detail;
    const linea = document.createElement('li');
    linea.textContent = `${new Date().toLocaleTimeString('es-ES')} · ${titulo} → ${estado}`;
    contenedor.prepend(linea);
  }, { signal });
}
// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { HOY } from './util/fechas.js';
import { conectarTablero } from './vista/controlador.js';
import { conectarRegistro } from './vista/registro.js';
import { EVENTOS } from './vista/eventos.js';

const tablero = new Tablero('Taller Nómada', crearBacklog());

conectarTablero({
  lista: document.querySelector('#lista-tareas'),
  resumen: document.querySelector('#resumen'),
  tablero,
  hoy: HOY
});

conectarRegistro(document.querySelector('#registro'));

// Un oyente global para depurar durante el desarrollo
document.addEventListener(EVENTOS.TAREA_CAMBIADA, (e) => {
  console.log('[evento]', e.type, e.detail, '· esfuerzo ahora:', tablero.esfuerzo);
});

Un clic en «Empezar» sobre la carpintería produce ahora, en cascada y sin que ninguna pieza conozca a las demás:

[evento] tarea:cambiada { id: 6, estado: 'en-curso', titulo: 'Presupuesto de la carpintería' } · esfuerzo ahora: 124

Y en la pantalla: el <li> repintado, el resumen actualizado y una línea nueva en el registro de actividad.

Cuándo usar eventos personalizados y cuándo no. Son ideales para comunicar de forma ascendente o lateral (un componente informa de que algo pasó, sin saber quién escucha). No son adecuados para comunicaciones de uno a uno donde una llamada a función directa es más clara: si el controlador necesita el resultado inmediato de algo, llámalo. Y ojo con abusar: una aplicación donde todo se comunica por eventos es difícil de seguir, porque la traza deja de leerse de arriba abajo. Un puñado de eventos bien nombrados es una arquitectura; treinta son un laberinto.

  1. AbortController: retirar muchos manejadores de golpe

Recuerda el problema de 06-03: para retirar un manejador hace falta la referencia exacta de la función. Con diez manejadores repartidos, la limpieza se convierte en diez líneas y diez variables.

AbortController lo resuelve. Es un objeto con una propiedad signal y un método abort(). Si pasas esa signal como opción a addEventListener, todos los manejadores registrados con ella se retiran al llamar a abort():

const controlador = new AbortController();
const { signal } = controlador;

lista.addEventListener('click', alHacerClic, { signal });
barra.addEventListener('click', alFiltrar,  { signal });
document.addEventListener('keydown', alPulsarTecla, { signal });
window.addEventListener('resize', alRedimensionar, { signal });

// Una sola línea retira los cuatro:
controlador.abort();

Esto encaja perfectamente con el patrón de la lección: cada función conectarX acepta una signal y la propaga, de modo que desmontar la interfaz entera es trivial.

// js/app.js
const controlador = new AbortController();
const { signal } = controlador;

conectarTablero({ lista, resumen, tablero, hoy: HOY, signal });
conectarRegistro(document.querySelector('#registro'), { signal });

// Por ejemplo, al cambiar de vista o al recargar los datos:
// controlador.abort();   ← toda la interacción queda desconectada de golpe

Detalles que conviene saber:

  • Una signal abortada no se puede reutilizar: hay que crear un AbortController nuevo. Si registras un manejador con una señal ya abortada, simplemente no se registra.
  • signal y once se combinan sin problema: { once: true, signal }.
  • AbortController no es exclusivo de los eventos: es el mecanismo estándar de cancelación en el navegador, y lo volverás a usar para cancelar peticiones de red en Peticiones Robustas.

Errores Comunes y Consejos

  • Usar evento.target donde hacía falta closest(). Si el botón contiene un icono, target será el icono. evento.target.closest('[data-accion]') es el patrón correcto, siempre.
  • Olvidar la guarda if (elemento === null) return; en un manejador delegado. Recibes todos los clics del contenedor, incluidos los que no van a ningún sitio, y sin guarda obtienes un TypeError.
  • Olvidar bubbles: true en un CustomEvent. El evento se dispara, no falla nada y nadie lo oye. Es el fallo más desconcertante de la lección, porque no hay ningún síntoma.
  • Abusar de stopPropagation(). Rompe funcionalidades que ni siquiera existen todavía y no deja rastro. Prefiere una guarda en el manejador de arriba.
  • Creer que preventDefault() detiene el burbujeo. No lo hace, y stopPropagation() no cancela la acción por defecto. Son ejes independientes: repasa la tabla del apartado 5.
  • Intentar delegar focus o blur. No burbujean. Usa focusin/focusout, o registra con { capture: true }.
  • Delegar en document cuando bastaba el contenedor. Cuanto más cerca esté el manejador del objetivo, menos eventos irrelevantes tendrá que descartar. Delega en el <ul>, no en document.
  • Reutilizar un AbortController ya abortado. No vuelve a funcionar. Crea uno nuevo cada vez que montes la interfaz.
  • Consejo: nombra los eventos con dominio:accion. Evita colisiones y documenta la intención. Y centraliza las cadenas en un módulo EVENTOS, como en el apartado 11, para que un error tipográfico sea un undefined visible y no un evento que nadie escucha.
  • Consejo: usa getEventListeners($0) en la consola de Chrome. Muestra todos los manejadores del elemento seleccionado; es la mejor forma de verificar que la delegación no dejó manejadores duplicados por el camino.

Ejercicios

Ejercicio 1 · El experimento de las fases

Registra seis manejadores de click —captura y burbujeo en section, ul y li— y haz un clic en el botón de una tarea. Predice por escrito el orden antes de ejecutar, y luego compruébalo. Después añade stopPropagation() en el manejador de captura del <ul> y explica exactamente qué manejadores dejan de ejecutarse y por qué.

Ejercicio 2 · Delegación con dos acciones y confirmación

Amplía el manejador delegado de #lista-tareas para que soporte una tercera acción, data-accion="archivar", que oculte el <li> con hidden. Antes de archivar debe emitir un evento tarea:antes-de-archivar cancelable, y solo continuar si nadie lo ha vetado. Después escribe un oyente que vete el archivado de cualquier tarea que no esté hecha. Todo con un único addEventListener en el <ul> (más el oyente del veto).

Ejercicio 3 · Panel de carga desacoplado

Crea js/vista/carga.js con una función conectarCarga(contenedor, tablero, { signal }) que dibuje las horas abiertas por responsable (Iván 25, Lucía 14, Marta 6) y se actualice sola cada vez que se emita tarea:cambiada, sin que el controlador del tablero tenga que saber que este panel existe. Usa AbortController en app.js para poder desconectarlo todo desde la consola.

Soluciones

Ejercicio 1

for (const [nombre, elem] of [['section', section], ['ul', ul], ['li', li]]) {
  elem.addEventListener('click', () => console.log(`captura  · ${nombre}`), { capture: true });
  elem.addEventListener('click', () => console.log(`burbujeo · ${nombre}`));
}

Orden al hacer clic en el botón:

captura  · section
captura  · ul
captura  · li
burbujeo · li
burbujeo · ul
burbujeo · section

Primero la bajada completa (de fuera hacia dentro), luego la subida completa (de dentro hacia fuera). El <li> aparece en las dos listas porque tiene un manejador en cada fase; como el objetivo real es el <button>, para el <li> ambas son fases de recorrido normales.

Con stopPropagation() en el manejador de captura del <ul>, la salida se reduce a:

captura  · section
captura  · ul

Se corta todo lo demás: el evento ni siquiera llega al <li>, ni al <button> (que era el objetivo), ni vuelve a subir. Es la demostración más contundente de por qué stopPropagation en captura es tan destructivo: cancela el evento para todo el subárbol, incluido el elemento donde el usuario hizo clic de verdad.

Ejercicio 2

import { emitir, EVENTOS } from './eventos.js';

lista.addEventListener('click', (evento) => {
  const boton = evento.target.closest('button[data-accion]');
  if (boton === null || !lista.contains(boton)) return;

  const li = boton.closest('li[data-id]');
  const tarea = tablero.buscarPorId(Number(li.dataset.id));
  if (tarea === null) return;

  if (boton.dataset.accion === 'archivar') {
    const permitido = li.dispatchEvent(new CustomEvent('tarea:antes-de-archivar', {
      bubbles: true, cancelable: true, detail: { id: tarea.id, estado: tarea.estado }
    }));
    if (!permitido) {
      console.warn(`Archivado vetado para «${tarea.titulo}» (${tarea.estado}).`);
      return;
    }
    li.hidden = true;
    emitir(lista, EVENTOS.TABLERO_ACTUALIZADO, tablero.resumen(HOY));
    return;
  }

  // … las acciones 'avanzar' y 'reabrir', como en el apartado 9
});

// El veto, en otro módulo que el controlador desconoce por completo
document.addEventListener('tarea:antes-de-archivar', (evento) => {
  if (evento.detail.estado !== 'hecha') evento.preventDefault();
});

Dos claves. La primera: dispatchEvent devuelve false si algún manejador llamó a preventDefault(), y eso solo funciona si el evento se creó con cancelable: true; sin esa opción, preventDefault() se ignora y dispatchEvent devuelve siempre true. La segunda: el controlador no contiene la regla «solo se archiva lo hecho». Esa regla vive en otro módulo, y podría cambiarse o retirarse sin tocar el controlador. Es exactamente el desacoplamiento que buscamos.

Ejercicio 3

// js/vista/carga.js
import { EVENTOS } from './eventos.js';

export function conectarCarga(contenedor, tablero, { signal } = {}) {
  function pintar() {
    const horas = tablero.horasPorResponsable();      // { Iván: 25, Lucía: 14, Marta: 6 }
    const total = Object.values(horas).reduce((s, h) => s + h, 0);

    contenedor.replaceChildren();                     // vaciar sin innerHTML
    for (const [quien, h] of Object.entries(horas).sort((a, b) => b[1] - a[1])) {
      const fila = document.createElement('li');
      fila.textContent = `${quien}: ${h} h (${Math.round((h / total) * 100)} %)`;
      fila.style.setProperty('--porcentaje', `${(h / total) * 100}%`);
      contenedor.append(fila);
    }
  }

  pintar();
  document.addEventListener(EVENTOS.TAREA_CAMBIADA, pintar, { signal });
}
// js/app.js
const controlador = new AbortController();
const { signal } = controlador;

conectarTablero({ lista, resumen, tablero, hoy: HOY, signal });
conectarCarga(document.querySelector('#carga'), tablero, { signal });

globalThis.desconectar = () => controlador.abort();   // para probar desde la consola

El panel arranca con Iván: 25 h (56 %), Lucía: 14 h (31 %), Marta: 6 h (13 %), sumando las 45 h abiertas canónicas. Al marcar la carpintería como hecha, Iván baja a 20 h y todos los porcentajes se recalculan solos, sin que controlador.js mencione este módulo ni una vez: la comunicación pasa entera por el evento tarea:cambiada. Y ejecutando desconectar() en la consola, el abort() retira de golpe el manejador delegado del tablero y el del panel de carga. Los métodos createElement, append y replaceChildren que aparecen aquí son justo el tema de la lección siguiente.

Conclusión

Ya sabes cómo viaja un evento y cómo aprovecharlo. El recorrido tiene tres fases —captura de fuera hacia dentro, objetivo, y burbujeo de dentro hacia fuera—, addEventListener registra en burbujeo salvo que pidas { capture: true }, y durante todo el trayecto target no cambia (es donde ocurrió el evento) mientras currentTarget es distinto en cada manejador. Puedes interrumpir el viaje con stopPropagation() y, de forma más contundente, con stopImmediatePropagation(), que además cancela los otros manejadores del mismo elemento; y sabes que ninguno de los dos es lo mismo que preventDefault(), que cancela la acción por defecto del navegador y no toca la propagación. También sabes que stopPropagation es una decisión con consecuencias globales y que casi siempre es preferible una guarda en el manejador de arriba. Conoces los eventos que no burbujean —focus, blur, load, error, mouseenter— y sus alternativas: focusin/focusout o la fase de captura.

Sobre esa base tienes el patrón más rentable del módulo: la delegación. Un único manejador en ul#lista-tareas que atiende los clics de todos los botones de todas las tareas, con la forma canónica de cuatro pasos: evento.target.closest('[data-accion]'), guarda contra null, closest('li[data-id]') para saber de qué tarea se trata, y Number(li.dataset.id) para cruzar la frontera hacia el modelo. Funciona con elementos que aún no existen —que es la razón principal para usarla y lo que hará que la lección siguiente no rompa nada—, consume una fracción de la memoria y concentra toda la lógica de interacción en un sitio.

Y tienes el mecanismo para que las piezas de la aplicación se hablen sin conocerse: los eventos personalizados. new CustomEvent('tarea:cambiada', { bubbles: true, detail }) y dispatchEvent, con la convención dominio:accion, el recordatorio de que bubbles es false por defecto y de que el disparo es síncrono, y la posibilidad de crear eventos cancelable que funcionan como una petición de permiso. Con ellos, el controlador del tablero se limita a anunciar lo que ha pasado, y el resumen, el registro de actividad y el panel de carga por responsable —25 h de Iván, 14 de Lucía, 6 de Marta— se actualizan solos. Cierra el conjunto AbortController: una signal compartida que permite retirar de un plumazo todos los manejadores registrados con ella, el mismo mecanismo de cancelación que reaparecerá con las peticiones de red en el Módulo 7.

Queda una carencia grande y muy visible: la lista sigue teniendo unos pocos <li> escritos a mano en el HTML, mientras el backlog tiene seis tareas y en 06-07 podrás dar de alta más. La página no puede seguir siendo un molde fijo que rellenamos; tiene que construirse a partir del modelo. Cómo se crea un elemento desde cero, cómo se inserta en el sitio exacto, cómo se elimina sin dejar fugas de memoria, cómo se insertan cien nodos sin castigar al navegador y cómo se clona una plantilla declarada en el HTML es Creación y Eliminación de Elementos del DOM, donde escribirás vista/tarjeta.js y convertirás por fin una Tarea del modelo en su <li> completo.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados