Pourquoi l’event delegation mérite votre attention

Dans beaucoup de projets frontend, on commence par ajouter un écouteur d’événement sur chaque bouton, chaque ligne de tableau, chaque carte ou chaque élément interactif. Cela fonctionne très bien sur une petite interface. Mais dès que le DOM devient dynamique, que des éléments sont ajoutés après le chargement initial ou que la page contient des dizaines d’actions similaires, cette approche devient vite fragile.

L’event delegation consiste à placer un seul écouteur sur un élément parent, puis à déterminer quel élément enfant a déclenché l’événement. Cette technique s’appuie sur la propagation des événements dans le DOM, plus précisément sur le bubbling : lorsqu’un bouton est cliqué, l’événement remonte progressivement depuis ce bouton vers ses parents.

Cette approche est particulièrement utile pour les listes, les menus, les tableaux, les composants rendus dynamiquement et les interfaces enrichies progressivement. Elle complète bien une démarche de progressive enhancement, où le HTML reste fonctionnel avant d’être amélioré par JavaScript.

Le problème des écouteurs attachés un par un

Imaginons une liste de tâches avec un bouton de suppression sur chaque élément :

<ul class="todo-list">
  <li>
    Apprendre les événements DOM
    <button type="button" class="delete-button">Supprimer</button>
  </li>
  <li>
    Refactorer le JavaScript
    <button type="button" class="delete-button">Supprimer</button>
  </li>
</ul>

Une première solution consiste à sélectionner tous les boutons et à leur attacher un écouteur :

const buttons = document.querySelectorAll<HTMLButtonElement>(".delete-button");

buttons.forEach((button) => {
  button.addEventListener("click", () => {
    button.closest("li")?.remove();
  });
});

Ce code est simple, mais il a une limite importante : il ne concerne que les boutons présents au moment où le script s’exécute. Si une nouvelle tâche est ajoutée ensuite, son bouton ne possède aucun écouteur.

On peut bien sûr penser à réattacher les événements après chaque mise à jour du DOM. Mais cette stratégie devient rapidement répétitive et source d’erreurs, notamment dans les interfaces construites par fragments, les widgets intégrés ou les pages sans framework.

Comprendre la propagation des événements

Lorsqu’un événement comme click se produit, il traverse plusieurs phases. La plus utilisée en pratique est la phase de remontée, ou bubbling. Un clic sur un bouton peut donc être capturé depuis son parent, son grand-parent, puis le document.

Avec l’event delegation, on écoute le clic sur la liste plutôt que sur chaque bouton :

const list = document.querySelector<HTMLUListElement>(".todo-list");

list?.addEventListener("click", (event) => {
  const target = event.target;

  if (!(target instanceof Element)) {
    return;
  }

  const button = target.closest<HTMLButtonElement>(".delete-button");

  if (!button) {
    return;
  }

  button.closest("li")?.remove();
});

Ici, un seul écouteur suffit. Même si de nouveaux éléments sont insérés dans la liste, le clic remontera toujours jusqu’au parent .todo-list.

L’utilisation de closest() est importante. Un clic peut partir d’un élément imbriqué dans le bouton, par exemple une icône ou un span. Tester uniquement event.target.matches(".delete-button") serait donc moins robuste.

Structurer plusieurs actions avec data-action

L’event delegation devient encore plus intéressante quand plusieurs actions cohabitent dans une même zone. Au lieu de multiplier les classes utilitaires, on peut utiliser un attribut data-action :

<ul class="todo-list">
  <li data-id="42">
    <span>Préparer un cours JavaScript</span>
    <button type="button" data-action="complete">Terminer</button>
    <button type="button" data-action="delete">Supprimer</button>
  </li>
</ul>

Puis centraliser la logique :

type TodoAction = "complete" | "delete";

const actions: Record<TodoAction, (item: HTMLLIElement) => void> = {
  complete(item) {
    item.classList.toggle("is-complete");
  },
  delete(item) {
    item.remove();
  }
};

const list = document.querySelector<HTMLUListElement>(".todo-list");

list?.addEventListener("click", (event) => {
  const target = event.target;

  if (!(target instanceof Element)) {
    return;
  }

  const button = target.closest<HTMLButtonElement>("button[data-action]");
  const item = target.closest<HTMLLIElement>("li[data-id]");

  if (!button || !item || !list.contains(button)) {
    return;
  }

  const action = button.dataset.action as TodoAction | undefined;

  if (!action || !(action in actions)) {
    return;
  }

  actions[action](item);
});

Cette organisation a plusieurs avantages : le HTML déclare l’intention, le JavaScript centralise les comportements, et TypeScript permet de limiter les actions autorisées. On retrouve une logique proche de celle utilisée dans des architectures plus explicites, par exemple avec des machines à états en TypeScript lorsque les interactions deviennent plus complexes.

Ajouter des éléments dynamiquement sans réattacher les événements

Voici un exemple d’ajout de tâche. Aucun nouvel écouteur n’est nécessaire :

const form = document.querySelector<HTMLFormElement>(".todo-form");
const input = document.querySelector<HTMLInputElement>("#todo-title");
const list = document.querySelector<HTMLUListElement>(".todo-list");

form?.addEventListener("submit", (event) => {
  event.preventDefault();

  if (!input || !list || input.value.trim() === "") {
    return;
  }

  const item = document.createElement("li");
  item.dataset.id = crypto.randomUUID();
  item.innerHTML = `
    <span></span>
    <button type="button" data-action="complete">Terminer</button>
    <button type="button" data-action="delete">Supprimer</button>
  `;

  const label = item.querySelector("span");
  if (label) {
    label.textContent = input.value;
  }

  list.append(item);
  input.value = "";
});

On pourrait aller plus loin avec un rendu plus strict pour éviter innerHTML, mais l’idée principale reste la même : les boutons ajoutés après coup fonctionnent parce que l’écouteur est placé sur le parent.

Si votre interface réagit à des changements DOM externes, l’event delegation peut également être combinée avec MutationObserver, mais il faut éviter de l’utiliser pour compenser une architecture confuse. Dans beaucoup de cas, la délégation suffit.

Attention aux limites

L’event delegation n’est pas adaptée à tous les événements. Certains événements ne remontent pas de la même manière, ou pas du tout. Par exemple, focus et blur ne se comportent pas comme click, même si focusin et focusout peuvent souvent les remplacer pour une logique déléguée.

Il faut aussi choisir le bon parent. Attacher tous les événements au document est rarement nécessaire. Plus le parent est proche de la zone concernée, plus le code reste lisible, prévisible et facile à maintenir.

Un autre point important concerne les composants isolés. Avec le Shadow DOM, utilisé notamment dans les Custom Elements, les événements peuvent être retargetés. Certains événements personnalisés doivent être explicitement configurés avec bubbles: true et composed: true pour traverser correctement les frontières du shadow tree.

this.dispatchEvent(
  new CustomEvent("todo-delete", {
    detail: { id: this.todoId },
    bubbles: true,
    composed: true
  })
);

Cette configuration est essentielle si un composant web doit communiquer avec une couche applicative située plus haut dans le document.

Accessibilité : ne pas déléguer au détriment du HTML

L’event delegation ne doit pas servir à transformer n’importe quel élément en bouton. Un div avec un gestionnaire de clic n’offre pas automatiquement le comportement clavier, le rôle sémantique ou les attentes des technologies d’assistance.

Préférez toujours les éléments natifs :

<button type="button" data-action="open-menu">
  Ouvrir le menu
</button>

Un bouton fonctionne au clavier, expose une sémantique correcte et s’intègre mieux aux lecteurs d’écran. Cette logique rejoint les principes présentés dans l’article sur les formulaires accessibles : le bon HTML réduit la quantité de JavaScript nécessaire.

Si une action modifie dynamiquement l’interface, pensez aussi aux annonces accessibles. Par exemple, une suppression ou une validation peut nécessiter une zone de statut avec aria-live, comme expliqué dans l’article sur les ARIA live regions.

Bonnes pratiques d’architecture

Pour utiliser l’event delegation proprement, je recommande quelques règles simples.

D’abord, déléguez à l’échelle d’un composant ou d’une zone fonctionnelle, pas globalement. Une liste, un tableau, une barre d’outils ou un menu sont de bons candidats.

Ensuite, séparez la détection de l’action et son exécution. Le gestionnaire d’événement doit rester court : il identifie la cible, valide le contexte, puis appelle une fonction métier.

Enfin, évitez de dépendre d’une structure HTML trop précise. Utiliser closest("[data-action]") est souvent plus robuste que remonter manuellement avec parentElement?.parentElement.

Conclusion

L’event delegation est une technique simple, mais elle change profondément la manière d’écrire des interactions DOM maintenables. Au lieu de disperser des écouteurs sur chaque élément, on s’appuie sur la propagation naturelle des événements et on centralise la logique au bon niveau.

Elle permet de mieux gérer les contenus dynamiques, de réduire le code répétitif et d’améliorer la robustesse des interfaces. Comme souvent en développement web, la solution efficace ne consiste pas à ajouter une bibliothèque, mais à mieux comprendre les mécanismes natifs de la plateforme.