Ein Listener statt einer Client-Komponente pro Button
In dem Moment, in dem du einer Komponente Klick-Analytics hinzufügst, wird sie zu einer Client-Komponente, die JavaScript ausliefert. Mach das über eine ganze Website hinweg, und allein das Tracking bläht dein Bundle auf. Hier ist die Alternative, die seit jeher in der Plattform steckt: ein einziger delegierter Listener, deklarative Data-Attribute und Komponenten, die reines serverseitig gerendertes HTML bleiben.
Hier ist eine Abfolge, die sich in fast jeder Codebasis wiederholt. Ein Product Manager bittet dich, Klicks auf einen Button zu tracken. Der Button ist eine schöne, statische, serverseitig gerenderte Komponente. Also fügst du einen onClick-Handler hinzu. Um einen onClick-Handler zu haben, muss die Komponente jetzt im Browser laufen — also fügst du "use client" hinzu (oder greifst zu einem Hook oder einem Wrapper). Die Komponente hydratisiert. Sie liefert JavaScript aus. Und das alles nicht, um auf dem Client irgendetwas zu tun, sondern um deinem Analytics eine einzige Zeile zuzuflüstern.
Multipliziere das nun mit jedem getrackten Button, jeder Karte, jedem Link und jedem Tab auf einer großen Website. Ein beträchtlicher Teil deines JavaScript-Bundles existiert einzig dazu, Klicks zu melden. In diesem Artikel geht es um die Alternative — jene, die seit den 1990er-Jahren im Browser sitzt und kein Framework braucht: Event-Delegation. Ein kleiner Listener beobachtet die gesamte Seite; deine Komponenten werden wieder zu reinem HTML.
Der Handler, den du ohne Nachdenken hinzufügst
Der Reflex wirkt harmlos. Du hast einen Link, du willst wissen, wann er geklickt wird, also hängst du einen Handler an:
"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>
);
}Eine Zeile Tracking, und die ganze Komponente hat die Kategorie gewechselt. Sie ist kein statisches Markup mehr, das der Server streamen und vergessen kann — sie ist eine interaktive Insel, die der Client herunterladen, parsen und hydratisieren muss, bevor dieser onClick überhaupt existiert. Das Framework kann nicht erkennen, dass der Handler nur Fire-and-Forget-Telemetrie ist; soweit es weiß, muss diese Komponente auf dem Client lebendig sein.
Dieser Reflex bringt Kosten mit sich, die sich über eine Codebasis hinweg summieren:
- Jede getrackte Komponente wird zu einer Hydratationsgrenze — JavaScript, das heruntergeladen, geparst und ausgeführt wird für etwas, das die UI nie verändert.
- Die Tracking-Logik ist über Hunderte von Komponenten verstreut, von denen jede das Analytics-SDK importiert, sodass das SDK in vielen Bundles landet.
- Die Analytics-Bibliothek lädt auf dem kritischen Pfad und konkurriert mit dem Rendering, auf das der Nutzer tatsächlich wartet.
- "Welche Events feuern wir, und von wo?" wird zu einem archäologischen Projekt — die Antwort ist über den gesamten Komponentenbaum verteilt.
Warum es sich richtig anfühlt
Es lohnt sich, ehrlich zu sein, warum wir das tun. Co-Location ist ein wirklich guter Instinkt: Der Klick und das, was du über den Klick festhalten willst, wohnen am selben Ort, also liest sich der Tracking-Aufruf direkt dort gut und ist leicht nachzuvollziehen. Frameworks bestärken das — eine onClick-Prop ist der naheliegende, dokumentierte Weg, auf einen Klick zu reagieren, und sie schleppt zufällig die Hydratation gleich mit.
Doch die Absicht zu tracken am selben Ort zu halten, erfordert nicht, den Code am selben Ort zu halten, der trackt. Du kannst die Deklaration neben dem Element belassen — "dieser Button ist ein CTA-Klick" — während das eigentliche Zuhören ganz woanders geschieht. Der Browser konnte das schon immer; wir haben nur aufgehört, danach zu greifen, sobald Komponenten Handler pro Element kostenlos erscheinen ließen.
Events steigen auf — das ist der ganze Trick
Wenn du ein Element anklickst, feuert der Browser das Event nicht nur auf diesem Element. Das Event wandert den DOM-Baum hinauf — vom Element zu seinem Elternelement, zu dessen Elternelement, den ganzen Weg bis zu document. Das ist Bubbling, und es bedeutet, dass ein einziger Listener ganz oben Klicks auf alles darunter beobachten kann. Du brauchst keinen Listener pro Button; du brauchst einen Listener, der das Dokument beobachtet und pro Klick herausfindet, was tatsächlich geklickt wurde.
Das Werkzeug dafür ist Element.closest(). Ausgehend von dort, wo der Klick gelandet ist — das könnte ein Icon oder ein <span> sein, das in deinem Link verschachtelt ist — läuft closest() nach oben, bis es einen Vorfahren findet, der zu einem Selektor passt. Frag es nach dem nächstgelegenen Element mit einem Tracking-Marker, und du bekommst genau den Button zurück, der geklickt wurde, unabhängig davon, was direkt unter dem Zeiger lag. Dieses eine Primitiv ersetzt jeden Handler pro Komponente.
Der Vertrag ist ein Data-Attribut
Wenn ein globaler Listener den Klick behandeln soll, besteht die einzige Aufgabe der Komponente darin, zu deklarieren, was getrackt werden soll — im Markup, wo ein Server es rendern kann. HTML hat den Mechanismus bereits: data-*-Attribute. Der Button beschreibt sich selbst und liefert kein Verhalten aus:
// 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>
);
}Es gibt kein "use client", keinen Handler, kein importiertes SDK — nichts, das der Client hydratisieren müsste. Der Server streamt schlichtes HTML, und die Tracking-Absicht reist als Attribute mit. Das ist es, was tatsächlich im Browser ankommt:
<!-- 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>Ein Listener für die ganze Seite
Jetzt lebt das Verhalten an genau einem Ort. Ein einziger Klick-Listener auf document liest den Marker von dem ab, was geklickt wurde, und meldet ihn. Das data-track der Komponente wird zum Event-Namen; die übrigen data-*-Attribute werden zur Payload — die dataset-API reicht sie als schlichtes Objekt herüber:
// 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);
}Du registrierst ihn einmal, beim Anwendungsstart, vor der Hydratation — sodass er vom allerersten Paint an beobachtet. Wo dieser Aufruf lebt, hängt von deinem Stack ab, aber es ist immer ein einziger Aufruf:
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();Lade das SDK erst bei einem echten Klick
Hier gibt es einen subtilen, zweiten Gewinn. Der Listener selbst ist winzig — ein paar Zeilen ohne Abhängigkeiten. Der schwere Teil von Analytics ist das SDK, und mit Delegation brauchst du es während des Seitenladens nicht mehr. Du kannst seinen Import aufschieben, bis der erste Klick tatsächlich passiert:
// 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);
}Jetzt sind die Kilobytes des Analytics-Anbieters vollständig vom kritischen Pfad verschwunden. Nichts am Tracking konkurriert mit dem ersten Render; das SDK trifft lazy ein, auf Abruf, im Moment, in dem ein Nutzer zum ersten Mal interagiert — und für einen Nutzer, der ohne Klick abspringt, lädt es nie.
Die Details, die beißen
Klicks zu delegieren ist leicht zu 90 % richtig zu machen — und dann bricht man Power-Usern das Genick. Ein schlichter Linksklick gehört dir zur Behandlung, aber ein Cmd/Strg/Shift-Klick oder ein Mittelklick ist der Nutzer, der den Browser bittet, einen Link in einem neuen Tab zu öffnen oder herunterzuladen. Wenn dein Listener bedingungslos preventDefault() aufruft, kaperst du diese Absicht stillschweigend. Steig bei modifizierten und nicht-primären Klicks früh aus und lass das native href des Ankers seine Arbeit tun:
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
}Die zweite Falle ist das Timing. Wenn der Klick die Seite wegnavigiert, kann das Entladen eine Analytics-Anfrage abbrechen, die noch unterwegs war — sodass ausgerechnet die Klicks, die du am dringendsten messen willst, am ehesten verloren gehen. navigator.sendBeacon existiert genau dafür: Es übergibt eine kleine Payload dem Browser zur Auslieferung im Hintergrund und überlebt die 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 }),
);
}Für interne Links, die von einem clientseitigen Router behandelt werden, gilt nichts davon — es gibt kein Entladen, also ist eine normale Anfrage in Ordnung, und der delegierte Listener sitzt vergnügt neben dem Router. Der Beacon zählt bei Links, die eine echte, vollständige Seiten-Navigation auslösen. Zu wissen, welche deiner Links was tun, ist die ganze Feinheit.
Der Listener darf den Link niemals brechen. Respektiere modifizierte Klicks, nutze einen Beacon für Navigationen und behandle Analytics strikt als Best-Effort — ein Tracking-Fehler sollte für den Nutzer unsichtbar sein, niemals ein toter Button.
Über Klicks hinaus
Klicks sind der häufigste Fall, doch das Muster verallgemeinert sich auf jedes Event, das ein delegierter Listener beobachten kann. Ein natives <details>-Accordion etwa öffnet und schließt sich mit null JavaScript — und du kannst trotzdem messen, wie oft Leute es öffnen, denn das toggle-Event lässt sich genauso delegieren. Der eine Haken: toggle steigt nicht auf, also hörst du in der Capture-Phase zu:
// 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 bubbleFormular-Übermittlungen, Sichtbarkeitsänderungen, Medienwiedergabe — dieselbe deklarative Form gilt. Die Komponente erklärt im Markup, was aufzeichnungswürdig ist; eine Handvoll globaler Listener erledigt die Aufzeichnung. Die interaktive Oberfläche deiner Seite und die Beobachtbarkeit deiner Seite sind nicht länger dasselbe.
Warum das jetzt mehr zählt
Event-Delegation ist Jahrzehnte alt, und lange Zeit war sie ein Nice-to-have — eine Möglichkeit, einer Liste einen Listener anzuhängen statt tausend. In der Ära der Server Components steht mehr auf dem Spiel. Wenn der Standard eine Komponente ist, die auf dem Server rendert und kein JavaScript ausliefert, ist ein einzelnes onClick kein Rundungsfehler mehr: Es ist die Zeile, die einen ganzen Abschnitt von statischem HTML in eine hydratisierte Client-Insel umkippt.
Delegation ist das, was dir diesen Standard erhält. Analytics — historisch einer der häufigsten Gründe, warum eine präsentationale Komponente eine Client-Komponente "sein musste" — erzwingt die Grenze nicht mehr. Ganze Abschnitte, die nur getrackt und nicht interaktiv sein mussten, können serverseitig gerendert bleiben und nichts ausliefern. Die Kosten, deine UI zu beobachten, sind von den Kosten, sie zu hydratisieren, entkoppelt.
Delegation vs. pro Komponente, ehrlich betrachtet
| Aspekt | Handler in jeder Komponente | Ein delegierter Listener |
|---|---|---|
| JavaScript pro getracktem Element | Wird ausgeliefert (Hydratationsgrenze) | Keins — reines Server-HTML |
| Analytics-SDK | In vielen Bundles importiert | Einmal geladen, lazy, beim ersten Klick |
| Auf dem kritischen Pfad | Ja | Nein |
| Wo das Tracking lebt | Über den Baum verstreut | Eine Datei |
| Alle Events auditieren | Die ganze Codebasis durchgreppen | Die Marker / einen Handler lesen |
| Funktioniert für später hinzugefügte Elemente | Braucht jeweils einen Handler | Automatisch (Delegation) |
| Kopplung an das Framework | An den Komponenten-Lebenszyklus gebunden | Schlichtes DOM — framework-agnostisch |
Die ehrlichen Kosten der Delegation sind ein wenig Disziplin: Der Vertrag besteht jetzt aus stringly-typed Attributen statt aus einem typisierten Funktionsaufruf, sodass ein Tippfehler in einem data-track-Namen still fehlschlägt statt zur Kompilierzeit. Ein winziger Helfer, der die Attribute aus einem typisierten Argument baut, kauft die meiste davon Sicherheit zurück, und ein einziger gut typisierter Listener ist eine weit kleinere Fläche, die ehrlich zu halten ist, als über Hunderte von Dateien verstreute Handler.
Nichts davon ist neu
Wenn sich das vertraut anfühlt, sollte es das auch. Genau so haben Tag-Manager und Autocapture-Analytics schon immer funktioniert. Google Tag Manager, Segment, PostHog, Heap — unter der Haube hängen sie ein paar globale Listener an und lesen Attribute (oder leiten Selektoren ab) von dem ab, was geklickt wurde. Du hast dieses Muster mit ziemlicher Sicherheit bereits ausgeliefert; du hast es nur ein Drittanbieter-Skript besitzen lassen.
Der Sinn, es bewusst zu tun, liegt in Kontrolle und Gewicht. Rund dreißig Zeilen geben dir dieselbe Delegations-Mechanik ohne Anbieter-Skript auf deinem kritischen Pfad, ohne undurchsichtiges Autocapture, das Events feuert, die du nicht beabsichtigt hast, und mit einem Data-Attribut-Vertrag, den du lesen, typisieren und testen kannst. Du behältst die Ergonomie, die Autocapture beliebt gemacht hat, und lässt die Steuer weg.
Zum Mitnehmen
- Ein Tracking-
onClickzu einer präsentationalen Komponente hinzuzufügen, befördert sie stillschweigend zu einer Client-Komponente, die JavaScript ausliefert. - Du brauchst keinen Handler pro Element — Klicks steigen bis zu
documentauf, undclosest("[data-track]")stellt genau wieder her, was geklickt wurde. - Lass Komponenten das Tracking mit
data-*-Attributen deklarieren und halte das Verhalten in einem globalen Listener, der einmal beim Start registriert wird. - Importiere das Analytics-SDK lazy innerhalb des Listeners, damit es vom kritischen Pfad fernbleibt und für Nutzer, die nicht interagieren, nie lädt.
- Respektiere modifizierte/nicht-primäre Klicks, nutze
sendBeaconfür Navigationen und halte Analytics als Best-Effort, damit es niemals einen Link brechen kann. - In der Ära der Server Components ist Delegation das, was Analytics davon abhält, statische Abschnitte in hydratisierte Inseln zu verwandeln.
Keines der Teile hier ist exotisch: Event-Bubbling, closest(), data-*-Attribute, ein lazy import() und sendBeacon sind allesamt langweilige, gut unterstützte Plattform-Features. Die Verschiebung liegt darin, wo du das Verhalten platzierst. Hör auf, mit jeder Komponente einen Klick-Handler auszuliefern, liefere einen Listener für die ganze Seite, und lass deine Komponenten wieder das werden, was sie am besten rendern — HTML.