Un seul écouteur au lieu d'un composant client par bouton
Dès l'instant où vous ajoutez le suivi analytique d'un clic à un composant, celui-ci devient un composant client qui embarque du JavaScript. Multipliez ce geste à l'échelle d'un site et le suivi à lui seul fait gonfler votre bundle. Voici l'alternative, présente dans la plateforme depuis toujours : un unique écouteur délégué, des attributs de données déclaratifs, et des composants qui restent du pur HTML rendu côté serveur.
Voici un enchaînement qui se répète dans presque toutes les bases de code. Un chef de produit vous demande de suivre les clics sur un bouton. Ce bouton est un beau composant statique, rendu côté serveur. Vous ajoutez donc un gestionnaire onClick. Pour avoir un gestionnaire onClick, le composant doit désormais s'exécuter dans le navigateur — vous ajoutez donc "use client" (ou vous vous rabattez sur un hook, ou un wrapper). Le composant s'hydrate. Il embarque du JavaScript. Et il a fait tout cela non pas pour faire quoi que ce soit côté client, mais pour souffler une seule ligne à votre outil d'analytique.
Multipliez maintenant cela par chaque bouton, carte, lien et onglet suivi sur un grand site. Une part non négligeable de votre bundle JavaScript n'existe que pour signaler des clics. Cet article parle de l'alternative — celle qui repose dans le navigateur depuis les années 1990 et ne réclame aucun framework : la délégation d'événements. Un petit écouteur surveille toute la page ; vos composants redeviennent du pur HTML.
Le gestionnaire que vous ajoutez sans réfléchir
Le réflexe paraît inoffensif. Vous avez un lien, vous voulez savoir quand on clique dessus, alors vous y attachez un gestionnaire :
"use client"; // ← the moment analytics arrives, so does this line
import { track } from "@/lib/analytics";
export function CtaButton({ href, label, place }: Props) {
return (
<a
href={href}
onClick={() =>
track("cta_clicked", { place, label })
}
>
{label}
</a>
);
}Une ligne de suivi, et le composant tout entier a changé de catégorie. Ce n'est plus du balisage statique que le serveur peut streamer puis oublier — c'est un îlot interactif que le client doit télécharger, analyser et hydrater avant que ce onClick n'existe. Le framework ne peut pas deviner que le gestionnaire n'est que de la télémétrie du type « lancer et oublier » ; pour ce qu'il en sait, ce composant doit être vivant côté client.
Ce réflexe entraîne des coûts qui s'accumulent à l'échelle d'une base de code :
- Chaque composant suivi devient une frontière d'hydratation — du JavaScript téléchargé, analysé et exécuté pour quelque chose qui ne modifie jamais l'interface.
- La logique de suivi se disperse à travers des centaines de composants, chacun important le SDK d'analytique, si bien que le SDK se retrouve dans de nombreux bundles.
- La bibliothèque d'analytique se charge sur le chemin critique, en concurrence avec le rendu que l'utilisateur attend réellement.
- « Quels événements déclenchons-nous, et depuis où ? » devient un chantier d'archéologie — la réponse est éparpillée dans tout l'arbre des composants.
Pourquoi cela semble juste
Il vaut la peine d'être honnête sur les raisons qui nous poussent à agir ainsi. La colocalisation est un instinct véritablement sain : le clic et l'information que vous voulez enregistrer à son sujet vivent au même endroit, alors placer l'appel de suivi juste là se lit bien et se raisonne facilement. Les frameworks renforcent ce réflexe — une prop onClick est la façon évidente et documentée de réagir à un clic, et il se trouve qu'elle traîne l'hydratation avec elle.
Mais colocaliser l'intention de suivre n'exige pas de colocaliser le code qui suit. Vous pouvez garder la déclaration près de l'élément — « ce bouton est un clic sur un CTA » — tandis que l'écoute réelle se produit tout à fait ailleurs. Le navigateur a toujours su faire cela ; nous avons simplement cessé d'y recourir dès lors que les composants ont fait paraître gratuits les gestionnaires attachés à chaque élément.
Les événements remontent — c'est là toute l'astuce
Quand vous cliquez sur un élément, le navigateur ne déclenche pas seulement un événement sur cet élément. L'événement remonte l'arbre du DOM — de l'élément à son parent, puis au parent de celui-ci, jusqu'à document. C'est le bouillonnement (bubbling), et il signifie qu'un seul écouteur placé tout en haut peut observer les clics sur tout ce qui se trouve en dessous. Vous n'avez pas besoin d'un écouteur par bouton ; il vous faut un unique écouteur qui surveille le document et détermine, à chaque clic, ce qui a réellement été cliqué.
L'outil pour cela, c'est Element.closest(). En partant de l'endroit où le clic a atterri — qui pourrait être une icône ou un <span> imbriqué dans votre lien — closest() remonte jusqu'à trouver un ancêtre correspondant à un sélecteur. Demandez-lui l'élément le plus proche portant un marqueur de suivi, et vous récupérez le bouton exact qui a été cliqué, quel que soit ce qui se trouvait directement sous le pointeur. Cette seule primitive remplace tous les gestionnaires attachés composant par composant.
Le contrat, c'est un attribut de données
Si c'est un écouteur global qui va gérer le clic, la seule tâche du composant est de déclarer ce qui doit être suivi — dans le balisage, là où un serveur peut le rendre. HTML dispose déjà du mécanisme : les attributs data-*. Le bouton se décrit lui-même et n'embarque aucun comportement :
// No "use client". No handler. No hooks. Pure server-rendered HTML.
export function CtaButton({ href, label, place }: Props) {
return (
<a
href={href}
data-track="cta_clicked"
data-place={place}
data-label={label}
>
{label}
</a>
);
}Il n'y a pas de "use client", pas de gestionnaire, pas de SDK importé — rien que le client doive hydrater. Le serveur streame du HTML brut, et l'intention de suivi voyage avec, sous forme d'attributs. Voici ce qui parvient réellement au navigateur :
<!-- What the server sends. The browser needs nothing else to make it work. -->
<a href="/pricing" data-track="cta_clicked" data-place="hero" data-label="Start free">
Start free
</a>Un seul écouteur pour toute la page
Désormais, le comportement vit à un seul et unique endroit. Un unique écouteur de clic sur document lit le marqueur de ce qui a été cliqué et le signale. Le data-track du composant devient le nom de l'événement ; le reste des attributs data-* devient la charge utile — l'API dataset vous les remet sous forme d'objet simple :
// The entire client-side cost of analytics for the whole site.
function handleClick(e: MouseEvent) {
// Find the nearest tracked element from wherever the click landed —
// works even if the user clicked an icon or <span> inside the link.
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>("[data-track]");
if (!el) return;
const { track: event, ...data } = el.dataset;
send(event!, data);
}
export function registerTracking() {
document.addEventListener("click", handleClick);
}Vous l'enregistrez une seule fois, au démarrage de l'application, avant l'hydratation — de sorte qu'il observe dès le tout premier rendu. L'endroit où réside cet appel dépend de votre stack, mais c'est toujours un unique appel :
import { registerTracking } from "./track-delegation";
// Next.js: instrumentation-client.ts · Vite/SPA: main.ts · plain HTML: a <script>.
// One call, once, for the entire application.
registerTracking();Ne charger le SDK que lors d'un vrai clic
Il y a ici un second gain, subtil. L'écouteur en lui-même est minuscule — quelques lignes sans aucune dépendance. La partie lourde de l'analytique, c'est le SDK, et avec la délégation vous n'en avez plus besoin pendant le chargement de la page. Vous pouvez différer son importation jusqu'à ce que le premier clic se produise réellement :
// The listener ships ~1 KB. The heavy SDK is pulled only when a real
// click happens — never during page load.
async function send(event: string, data: Record<string, string | undefined>) {
const { track } = await import("@/lib/analytics"); // code-split, on demand
track(event, data);
}Désormais, les kilo-octets du fournisseur d'analytique sont entièrement hors du chemin critique. Rien, dans le suivi, ne concurrence le premier rendu ; le SDK arrive paresseusement, à la demande, à l'instant même où un utilisateur interagit pour la première fois — et pour un utilisateur qui repart sans cliquer, il ne se charge jamais du tout.
Les détails qui mordent
Déléguer les clics, il est facile d'y arriver à 90 % puis de casser l'expérience des utilisateurs avertis. Un simple clic gauche vous revient, mais un Cmd/Ctrl/Maj-clic ou un clic du milieu, c'est l'utilisateur qui demande au navigateur d'ouvrir un lien dans un nouvel onglet ou de le télécharger. Si votre écouteur appelle preventDefault() sans condition, vous détournez silencieusement cette intention. Sortez tôt sur les clics modifiés ou non primaires et laissez le href natif de l'ancre faire son travail :
function handleClick(e: MouseEvent) {
// Cmd/Ctrl/Shift/Alt-click and middle-click express a native intent
// (open in new tab, download). Let the browser handle those untouched.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
// ...find [data-track], send event
}Le deuxième piège, c'est le timing. Si le clic fait quitter la page, le déchargement peut interrompre une requête d'analytique encore en cours — de sorte que les clics mêmes que vous tenez le plus à mesurer sont ceux qui risquent le plus d'être perdus. navigator.sendBeacon existe précisément pour cela : il remet une petite charge utile au navigateur pour qu'il la délivre en arrière-plan, en survivant à la navigation :
function send(event: string, data: Record<string, string | undefined>) {
// A click that unloads the page can abort an in-flight fetch. sendBeacon
// hands the request to the browser to deliver even as navigation happens.
navigator.sendBeacon(
"/track",
JSON.stringify({ event, ...data }),
);
}Pour les liens internes gérés par un routeur côté client, rien de tout cela ne s'applique — il n'y a pas de déchargement, donc une requête normale convient et l'écouteur délégué cohabite tranquillement avec le routeur. Le beacon compte pour les liens qui provoquent une vraie navigation avec rechargement complet de la page. Savoir lesquels de vos liens font l'un ou l'autre, c'est là toute la subtilité.
L'écouteur ne doit jamais casser le lien. Respectez les clics modifiés, utilisez un beacon pour les navigations, et traitez l'analytique comme strictement « au mieux » — un échec de suivi doit rester invisible pour l'utilisateur, jamais un bouton mort.
Au-delà des clics
Les clics sont le cas courant, mais le motif se généralise à tout événement qu'un écouteur délégué peut observer. Un accordéon natif <details>, par exemple, s'ouvre et se ferme avec zéro JavaScript — et vous pouvez tout de même mesurer la fréquence à laquelle les gens l'ouvrent, car l'événement toggle peut être délégué de la même manière. Le seul hic : toggle ne remonte pas, alors vous écoutez dans la phase de capture :
// The same pattern, a different event. A native <details> accordion needs
// zero JavaScript to open — and one delegated listener to be measurable.
document.addEventListener("toggle", (e) => {
const el = e.target as HTMLElement;
if (el.matches("[data-track-open]") && (el as HTMLDetailsElement).open) {
send(el.dataset.trackOpen!, { ...el.dataset });
}
}, true); // capture: the toggle event does not bubbleSoumissions de formulaire, changements de visibilité, lecture de médias — la même forme déclarative s'applique. Le composant énonce, dans le balisage, ce qui mérite d'être enregistré ; une poignée d'écouteurs globaux se chargent de l'enregistrement. La surface interactive de votre page et l'observabilité de votre page cessent d'être une seule et même chose.
Pourquoi cela compte davantage aujourd'hui
La délégation d'événements a des décennies d'existence, et pendant longtemps elle a été un confort optionnel — une façon d'attacher un écouteur à une liste plutôt que mille. À l'ère des Server Components, les enjeux sont plus élevés. Quand la valeur par défaut est un composant qui se rend côté serveur et n'embarque aucun JavaScript, un simple onClick n'est plus une erreur d'arrondi : c'est la ligne qui fait basculer une section entière du HTML statique vers un îlot client hydraté.
La délégation est ce qui vous permet de conserver cette valeur par défaut. L'analytique — historiquement l'une des raisons les plus fréquentes pour lesquelles un composant purement présentationnel « devait » devenir un composant client — cesse de forcer la frontière. Des sections entières qui n'avaient besoin que d'être suivies, non d'être interactives, peuvent rester rendues côté serveur et n'embarquer rien. Le coût d'observer votre interface est découplé du coût de l'hydrater.
Délégation contre gestionnaire par composant, en toute honnêteté
| Aspect | Gestionnaire dans chaque composant | Un écouteur délégué |
|---|---|---|
| JavaScript par élément suivi | Embarqué (frontière d'hydratation) | Aucun — pur HTML serveur |
| SDK d'analytique | Importé dans de nombreux bundles | Chargé une fois, paresseusement, au premier clic |
| Sur le chemin critique | Oui | Non |
| Où vit le suivi | Éparpillé dans l'arbre | Un seul fichier |
| Auditer tous les événements | Grep sur toute la base de code | Lire les marqueurs / un gestionnaire |
| Fonctionne pour les éléments ajoutés plus tard | Nécessite un gestionnaire chacun | Automatiquement (délégation) |
| Couplage au framework | Lié au cycle de vie des composants | Pur DOM — indépendant du framework |
Le coût honnête de la délégation, c'est un peu de discipline : le contrat repose désormais sur des attributs typés « à la ficelle » plutôt que sur un appel de fonction typé, si bien qu'une faute de frappe dans un nom de data-track échoue silencieusement au lieu d'échouer à la compilation. Un petit utilitaire qui construit les attributs à partir d'un argument typé rachète l'essentiel de cette sûreté, et un unique écouteur bien typé représente une surface bien plus réduite à tenir honnête que des gestionnaires disséminés dans des centaines de fichiers.
Rien de tout cela n'est nouveau
Si cela vous semble familier, c'est normal. C'est exactement ainsi que les gestionnaires de balises et l'analytique par autocapture ont toujours fonctionné. Google Tag Manager, Segment, PostHog, Heap — sous le capot, ils attachent quelques écouteurs globaux et lisent des attributs (ou déduisent des sélecteurs) sur ce qui a été cliqué. Vous avez presque certainement déjà livré ce motif ; vous avez simplement laissé un script tiers en être propriétaire.
L'intérêt de le faire délibérément, c'est le contrôle et le poids. Une trentaine de lignes environ vous donnent la même mécanique de délégation sans script de fournisseur sur votre chemin critique, sans autocapture opaque déclenchant des événements que vous n'aviez pas prévus, et avec un contrat par attribut de données que vous pouvez lire, typer et tester. Vous gardez l'ergonomie qui a rendu l'autocapture populaire et vous laissez tomber la taxe.
À retenir
- Ajouter un
onClickde suivi à un composant présentationnel le promeut silencieusement au rang de composant client qui embarque du JavaScript. - Vous n'avez pas besoin d'un gestionnaire par élément — les clics remontent jusqu'à
document, etclosest("[data-track]")retrouve exactement ce qui a été cliqué. - Laissez les composants déclarer le suivi avec des attributs
data-*et gardez le comportement dans un seul écouteur global enregistré une fois au démarrage. - Importez paresseusement le SDK d'analytique à l'intérieur de l'écouteur, afin qu'il reste hors du chemin critique et ne se charge jamais pour les utilisateurs qui n'interagissent pas.
- Respectez les clics modifiés ou non primaires, utilisez
sendBeaconpour les navigations, et gardez l'analytique « au mieux » pour qu'elle ne puisse jamais casser un lien. - À l'ère des Server Components, la délégation est ce qui empêche l'analytique de transformer des sections statiques en îlots hydratés.
Aucune des pièces présentées ici n'est exotique : le bouillonnement d'événements, closest(), les attributs data-*, un import() paresseux et sendBeacon sont tous des fonctionnalités de plateforme banales et bien prises en charge. Le changement tient à l'endroit où vous placez le comportement. Cessez de livrer un gestionnaire de clic avec chaque composant, livrez un seul écouteur pour toute la page, et laissez vos composants redevenir ce qu'ils rendent le mieux — du HTML.