Skip to main content
Back to blog
JavaScriptAnalyticsEvent DelegationServer ComponentsPerformance

One listener instead of a client component per button

The moment you add click analytics to a component, it becomes a client component that ships JavaScript. Do that across a site and tracking alone bloats your bundle. Here's the alternative that has been in the platform forever: one delegated listener, declarative data attributes, and components that stay pure server-rendered HTML.

Published July 28, 202610 min read

Here is a sequence that repeats in almost every codebase. A product manager asks you to track clicks on a button. The button is a nice, static, server-rendered component. So you add an onClick handler. To have an onClick handler, the component now needs to run in the browser — so you add "use client" (or reach for a hook, or a wrapper). The component hydrates. It ships JavaScript. And it did all of that not to do anything on the client, but to whisper one line to your analytics.

Now multiply that by every tracked button, card, link, and tab across a large site. A meaningful slice of your JavaScript bundle exists purely to report clicks. This article is about the alternative — the one that has been sitting in the browser since the 1990s and needs no framework: event delegation. One small listener watches the whole page; your components go back to being pure HTML.

The handler you add without thinking

The reflex looks harmless. You have a link, you want to know when it's clicked, so you attach a handler:

cta-button.tsx
"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>
  );
}

One line of tracking, and the whole component changed category. It is no longer static markup the server can stream and forget — it is an interactive island the client must download, parse, and hydrate before that onClick exists. The framework can't tell that the handler is only fire-and-forget telemetry; as far as it knows, this component needs to be alive on the client.

That reflex carries costs that compound across a codebase:

  • Every tracked component becomes a hydration boundary — JavaScript downloaded, parsed, and executed for something that never changes the UI.
  • Tracking logic is scattered across hundreds of components, each importing the analytics SDK, so the SDK ends up in many bundles.
  • The analytics library loads on the critical path, competing with the rendering the user actually waits for.
  • "Which events do we fire, and from where?" becomes an archaeology project — the answer is spread across the entire component tree.

Why it feels right

It's worth being honest about why we do this. Co-location is a genuinely good instinct: the click and the thing you want to record about the click live in the same place, so putting the tracking call right there reads well and is easy to reason about. Frameworks reinforce it — an onClick prop is the obvious, documented way to react to a click, and it happens to drag hydration along with it.

But co-locating the intent to track does not require co-locating the code that tracks. You can keep the declaration next to the element — "this button is a CTA click" — while the actual listening happens somewhere else entirely. The browser has always been able to do this; we just stopped reaching for it once components made per-element handlers feel free.

Events bubble — that's the whole trick

When you click an element, the browser doesn't only fire an event on that element. The event travels up the DOM tree — from the element, to its parent, to its parent, all the way to document. This is bubbling, and it means a single listener at the top can observe clicks on everything beneath it. You don't need a listener per button; you need one listener that watches the document and figures out, per click, what was actually clicked.

The tool for that is Element.closest(). Starting from wherever the click landed — which might be an icon or a <span> nested inside your link — closest() walks up until it finds an ancestor matching a selector. Ask it for the nearest element carrying a tracking marker, and you get back the exact button that was clicked, regardless of what was directly under the pointer. That single primitive replaces every per-component handler.

The contract is a data attribute

If a global listener is going to handle the click, the component's only job is to declare what should be tracked — in the markup, where a server can render it. HTML already has the mechanism: data-* attributes. The button describes itself and ships no behavior:

cta-button.tsx
// 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>
  );
}

There is no "use client", no handler, no imported SDK — nothing for the client to hydrate. The server streams plain HTML, and the tracking intent rides along as attributes. This is what actually reaches the browser:

<!-- 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>

One listener for the whole page

Now the behavior lives in exactly one place. A single click listener on document reads the marker off whatever was clicked and reports it. The component's data-track becomes the event name; the rest of the data-* attributes become the payload — the dataset API hands them over as a plain object:

track-delegation.ts
// 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);
}

You register it once, at application startup, before hydration — so it is watching from the very first paint. Where that call lives depends on your stack, but it is always a single call:

app entry (runs once, before hydration)
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();

Load the SDK only on a real click

There is a subtle, second win here. The listener itself is tiny — a few lines with no dependencies. The heavy part of analytics is the SDK, and with delegation you no longer need it during page load. You can defer importing it until the first click actually happens:

track-delegation.ts
// 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);
}

Now the analytics vendor's kilobytes are off the critical path entirely. Nothing about tracking competes with first render; the SDK arrives lazily, on demand, the instant a user first interacts — and for a user who bounces without clicking, it never loads at all.

The details that bite

Delegating clicks is easy to get 90% right and then break power users. A plain left-click is yours to handle, but a Cmd/Ctrl/Shift-click or a middle-click is the user asking the browser to open a link in a new tab or download it. If your listener calls preventDefault() unconditionally, you silently hijack that intent. Bail out early on modified and non-primary clicks and let the anchor's native href do its job:

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
}

The second trap is timing. If the click navigates the page away, the unload can abort an analytics request that was still in flight — so the very clicks you most want to measure are the ones most likely to be lost. navigator.sendBeacon exists precisely for this: it hands a small payload to the browser to deliver in the background, surviving the 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 }),
  );
}

For internal links handled by a client-side router, none of this applies — there is no unload, so a normal request is fine and the delegated listener sits happily alongside the router. The beacon matters for links that cause a real, full-page navigation. Knowing which of your links do which is the entire subtlety.

The listener must never break the link. Respect modified clicks, use a beacon for navigations, and treat analytics as strictly best-effort — a tracking failure should be invisible to the user, never a dead button.

Beyond clicks

Clicks are the common case, but the pattern generalizes to any event that a delegated listener can observe. A native <details> accordion, for example, opens and closes with zero JavaScript — and you can still measure how often people open it, because the toggle event can be delegated the same way. The one wrinkle: toggle doesn't bubble, so you listen in the capture phase:

// 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 bubble

Form submissions, visibility changes, media playback — the same declarative shape applies. The component states, in markup, what is worth recording; a handful of global listeners do the recording. The interactive surface of your page and the observability of your page stop being the same thing.

Why this matters more now

Event delegation is decades old, and for a long time it was a nice-to-have — a way to attach one listener to a list instead of a thousand. In the Server Components era its stakes are higher. When the default is a component that renders on the server and ships no JavaScript, a single onClick is no longer a rounding error: it is the line that flips an entire section from static HTML into a hydrated client island.

Delegation is what lets you keep that default. Analytics — historically one of the most common reasons a presentational component "had to" be a client component — stops forcing the boundary. Whole sections that only needed to be tracked, not to be interactive, can stay server-rendered and ship nothing. The cost of observing your UI is decoupled from the cost of hydrating it.

Delegation vs per-component, honestly

ConcernHandler in every componentOne delegated listener
JavaScript per tracked elementShips (hydration boundary)None — pure server HTML
Analytics SDKImported in many bundlesLoaded once, lazily, on first click
On the critical pathYesNo
Where tracking livesScattered across the treeOne file
Auditing all eventsGrep the whole codebaseRead the markers / one handler
Works for elements added laterNeeds a handler eachAutomatically (delegation)
Coupling to the frameworkTied to component lifecyclePlain DOM — framework-agnostic

The honest cost of delegation is a small amount of discipline: the contract is now stringly-typed attributes instead of a typed function call, so a typo in a data-track name fails silently rather than at compile time. A tiny helper that builds the attributes from a typed argument buys most of that safety back, and a single well-typed listener is a far smaller surface to keep honest than handlers sprinkled across hundreds of files.

None of this is new

If this feels familiar, it should. It is exactly how tag managers and autocapture analytics have always worked. Google Tag Manager, Segment, PostHog, Heap — under the hood they attach a few global listeners and read attributes (or infer selectors) off whatever was clicked. You have almost certainly shipped this pattern already; you just let a third-party script own it.

The point of doing it deliberately is control and weight. Roughly thirty lines give you the same delegation mechanics without a vendor script on your critical path, without opaque autocapture firing events you didn't intend, and with a data-attribute contract you can read, type, and test. You keep the ergonomics that made autocapture popular and drop the tax.

Takeaways

  • Adding a tracking onClick to a presentational component silently promotes it to a client component that ships JavaScript.
  • You don't need a handler per element — clicks bubble to document, and closest("[data-track]") recovers exactly what was clicked.
  • Let components declare tracking with data-* attributes and keep the behavior in one global listener registered once at startup.
  • Lazy-import the analytics SDK inside the listener so it stays off the critical path and never loads for users who don't interact.
  • Respect modified/non-primary clicks, use sendBeacon for navigations, and keep analytics best-effort so it can never break a link.
  • In the Server Components era, delegation is what stops analytics from turning static sections into hydrated islands.

None of the pieces here are exotic: event bubbling, closest(), data-* attributes, a lazy import(), and sendBeacon are all boring, well-supported platform features. The shift is in where you put the behavior. Stop shipping a click handler with every component, ship one listener for the whole page, and let your components go back to being what they render best — HTML.