Skip to main content
Volver al blog
JavaScriptAnalyticsEvent DelegationServer ComponentsPerformance

Un listener en lugar de un componente cliente por botón

En cuanto añades analítica de clics a un componente, este se convierte en un componente cliente que envía JavaScript. Haz eso a lo largo de todo un sitio y solo el tracking infla tu bundle. Aquí está la alternativa que ha estado en la plataforma desde siempre: un único listener delegado, atributos de datos declarativos y componentes que siguen siendo HTML puro renderizado en el servidor.

Publicado 28 de julio de 202610 min de lectura

Aquí hay una secuencia que se repite en casi todas las bases de código. Un product manager te pide que registres los clics en un botón. El botón es un componente bonito, estático, renderizado en el servidor. Así que le añades un manejador onClick. Para tener un manejador onClick, el componente ahora necesita ejecutarse en el navegador — así que añades "use client" (o recurres a un hook, o a un wrapper). El componente se hidrata. Envía JavaScript. Y todo eso no para hacer nada en el cliente, sino para susurrarle una sola línea a tu analítica.

Ahora multiplica eso por cada botón, tarjeta, enlace y pestaña rastreados a lo largo de un sitio grande. Una porción considerable de tu bundle de JavaScript existe únicamente para reportar clics. Este artículo trata de la alternativa — la que ha estado en el navegador desde los años noventa y no necesita ningún framework: la delegación de eventos. Un pequeño listener vigila toda la página; tus componentes vuelven a ser HTML puro.

El manejador que añades sin pensar

El reflejo parece inofensivo. Tienes un enlace, quieres saber cuándo se hace clic en él, así que le adjuntas un manejador:

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>
  );
}

Una línea de tracking, y todo el componente cambió de categoría. Ya no es marcado estático que el servidor puede transmitir y olvidar — es una isla interactiva que el cliente debe descargar, parsear e hidratar antes de que ese onClick exista. El framework no puede saber que el manejador es solo telemetría de disparar-y-olvidar; hasta donde él sabe, este componente necesita estar vivo en el cliente.

Ese reflejo acarrea costes que se acumulan a lo largo de una base de código:

  • Cada componente rastreado se convierte en una frontera de hidratación — JavaScript descargado, parseado y ejecutado para algo que nunca cambia la interfaz.
  • La lógica de tracking queda esparcida por cientos de componentes, cada uno importando el SDK de analítica, así que el SDK acaba en muchos bundles.
  • La librería de analítica carga en la ruta crítica, compitiendo con el renderizado que el usuario realmente espera.
  • "¿Qué eventos disparamos, y desde dónde?" se convierte en un proyecto de arqueología — la respuesta está repartida por todo el árbol de componentes.

Por qué se siente correcto

Vale la pena ser honestos sobre por qué hacemos esto. La colocalización es un instinto genuinamente bueno: el clic y aquello que quieres registrar sobre el clic viven en el mismo sitio, así que poner ahí mismo la llamada de tracking se lee bien y es fácil de razonar. Los frameworks lo refuerzan — una prop onClick es la forma obvia y documentada de reaccionar a un clic, y da la casualidad de que arrastra la hidratación consigo.

Pero colocalizar la intención de rastrear no exige colocalizar el código que rastrea. Puedes mantener la declaración junto al elemento — "este botón es un clic de CTA" — mientras la escucha real ocurre en un lugar completamente distinto. El navegador siempre pudo hacer esto; simplemente dejamos de recurrir a ello en cuanto los componentes hicieron que los manejadores por elemento parecieran gratis.

Los eventos burbujean — ese es todo el truco

Cuando haces clic en un elemento, el navegador no dispara el evento solo en ese elemento. El evento viaja hacia arriba por el árbol del DOM — desde el elemento, a su padre, al padre de este, hasta llegar a document. Esto es el burbujeo, y significa que un único listener en la cima puede observar los clics en todo lo que hay debajo. No necesitas un listener por botón; necesitas un listener que vigile el documento y averigüe, en cada clic, qué se pulsó realmente.

La herramienta para eso es Element.closest(). Partiendo de donde aterrizó el clic — que podría ser un icono o un <span> anidado dentro de tu enlace — closest() sube hasta encontrar un ancestro que coincida con un selector. Pídele el elemento más cercano que lleve un marcador de tracking, y obtienes de vuelta exactamente el botón que se pulsó, sin importar qué había directamente bajo el puntero. Ese único primitivo reemplaza a cada manejador por componente.

El contrato es un atributo de datos

Si un listener global va a manejar el clic, el único trabajo del componente es declarar qué debe rastrearse — en el marcado, donde un servidor puede renderizarlo. HTML ya tiene el mecanismo: los atributos data-*. El botón se describe a sí mismo y no envía ningún comportamiento:

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>
  );
}

No hay "use client", ni manejador, ni SDK importado — nada que el cliente tenga que hidratar. El servidor transmite HTML plano, y la intención de tracking viaja como atributos. Esto es lo que realmente llega al navegador:

<!-- 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 listener para toda la página

Ahora el comportamiento vive en exactamente un lugar. Un único listener de clic en document lee el marcador de aquello en lo que se hizo clic y lo reporta. El data-track del componente se convierte en el nombre del evento; el resto de los atributos data-* se convierten en la carga útil — la API dataset los entrega como un objeto plano:

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);
}

Lo registras una vez, al arrancar la aplicación, antes de la hidratación — de modo que esté vigilando desde el primerísimo pintado. Dónde vive esa llamada depende de tu stack, pero siempre es una única llamada:

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();

Carga el SDK solo ante un clic real

Aquí hay una segunda victoria sutil. El listener en sí es diminuto — unas pocas líneas sin dependencias. La parte pesada de la analítica es el SDK, y con la delegación ya no lo necesitas durante la carga de la página. Puedes aplazar su importación hasta que ocurra el primer clic de verdad:

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);
}

Ahora los kilobytes del proveedor de analítica están del todo fuera de la ruta crítica. Nada del tracking compite con el primer render; el SDK llega de forma diferida, bajo demanda, en el instante en que un usuario interactúa por primera vez — y para un usuario que rebota sin hacer clic, nunca carga.

Los detalles que muerden

Delegar clics es fácil de acertar en un 90 % y luego romperles la experiencia a los usuarios avanzados. Un clic izquierdo simple es tuyo para manejarlo, pero un clic con Cmd/Ctrl/Shift o un clic central es el usuario pidiéndole al navegador que abra un enlace en una pestaña nueva o que lo descargue. Si tu listener llama a preventDefault() sin condiciones, secuestras esa intención en silencio. Sal temprano en los clics modificados y no primarios y deja que el href nativo del ancla haga su trabajo:

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
}

La segunda trampa es el momento. Si el clic navega y descarga la página, la descarga puede abortar una petición de analítica que aún estaba en vuelo — de modo que precisamente los clics que más quieres medir son los que más probablemente se pierdan. navigator.sendBeacon existe exactamente para esto: entrega una pequeña carga útil al navegador para que la reparta en segundo plano, sobreviviendo a la navegación:

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 }),
  );
}

Para los enlaces internos manejados por un router del lado del cliente, nada de esto aplica — no hay descarga, así que una petición normal está bien y el listener delegado convive felizmente junto al router. El beacon importa para los enlaces que provocan una navegación real de página completa. Saber cuáles de tus enlaces hacen qué es toda la sutileza.

El listener nunca debe romper el enlace. Respeta los clics modificados, usa un beacon para las navegaciones y trata la analítica como estrictamente de mejor esfuerzo — un fallo de tracking debería ser invisible para el usuario, jamás un botón muerto.

Más allá de los clics

Los clics son el caso común, pero el patrón se generaliza a cualquier evento que un listener delegado pueda observar. Un acordeón nativo <details>, por ejemplo, se abre y se cierra con cero JavaScript — y aun así puedes medir con qué frecuencia lo abre la gente, porque el evento toggle puede delegarse de la misma manera. El único matiz: toggle no burbujea, así que escuchas en la fase de captura:

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

Envíos de formularios, cambios de visibilidad, reproducción de medios — se aplica la misma forma declarativa. El componente declara, en el marcado, qué vale la pena registrar; un puñado de listeners globales hacen el registro. La superficie interactiva de tu página y la observabilidad de tu página dejan de ser la misma cosa.

Por qué esto importa más ahora

La delegación de eventos tiene décadas, y durante mucho tiempo fue algo agradable de tener — una forma de adjuntar un listener a una lista en lugar de mil. En la era de los Server Components lo que está en juego es mayor. Cuando lo predeterminado es un componente que se renderiza en el servidor y no envía JavaScript, un solo onClick ya no es un error de redondeo: es la línea que voltea toda una sección de HTML estático a una isla cliente hidratada.

La delegación es lo que te permite conservar ese valor predeterminado. La analítica — históricamente una de las razones más comunes por las que un componente presentacional "tenía que" ser un componente cliente — deja de forzar la frontera. Secciones enteras que solo necesitaban ser rastreadas, no ser interactivas, pueden seguir renderizadas en el servidor y no enviar nada. El coste de observar tu interfaz queda desacoplado del coste de hidratarla.

Delegación frente a por componente, honestamente

AspectoManejador en cada componenteUn listener delegado
JavaScript por elemento rastreadoSe envía (frontera de hidratación)Ninguno — HTML puro del servidor
SDK de analíticaImportado en muchos bundlesCargado una vez, de forma diferida, al primer clic
En la ruta críticaNo
Dónde vive el trackingEsparcido por el árbolUn archivo
Auditar todos los eventosGreppear toda la base de códigoLeer los marcadores / un manejador
Funciona para elementos añadidos más tardeNecesita un manejador cada unoAutomáticamente (delegación)
Acoplamiento al frameworkAtado al ciclo de vida del componenteDOM puro — agnóstico del framework

El coste honesto de la delegación es una pequeña dosis de disciplina: el contrato ahora son atributos tipados como cadenas en lugar de una llamada de función tipada, así que un error tipográfico en un nombre data-track falla en silencio en lugar de en tiempo de compilación. Un pequeño helper que construye los atributos a partir de un argumento tipado recompra la mayor parte de esa seguridad, y un único listener bien tipado es una superficie mucho menor que mantener honesta que manejadores esparcidos por cientos de archivos.

Nada de esto es nuevo

Si esto te resulta familiar, debería. Es exactamente cómo han funcionado siempre los gestores de etiquetas y la analítica de autocaptura. Google Tag Manager, Segment, PostHog, Heap — bajo el capó adjuntan unos pocos listeners globales y leen atributos (o infieren selectores) de aquello en lo que se hizo clic. Casi con toda seguridad ya has enviado este patrón; solo que dejaste que lo poseyera un script de terceros.

El sentido de hacerlo deliberadamente está en el control y el peso. Unas treinta líneas te dan la misma mecánica de delegación sin un script de proveedor en tu ruta crítica, sin una autocaptura opaca disparando eventos que no pretendías, y con un contrato de atributos de datos que puedes leer, tipar y testear. Conservas la ergonomía que hizo popular a la autocaptura y te deshaces del impuesto.

Conclusiones

  • Añadir un onClick de tracking a un componente presentacional lo asciende en silencio a un componente cliente que envía JavaScript.
  • No necesitas un manejador por elemento — los clics burbujean hasta document, y closest("[data-track]") recupera exactamente aquello en lo que se hizo clic.
  • Deja que los componentes declaren el tracking con atributos data-* y mantén el comportamiento en un único listener global registrado una vez al arrancar.
  • Importa de forma diferida el SDK de analítica dentro del listener para que se mantenga fuera de la ruta crítica y nunca cargue para los usuarios que no interactúan.
  • Respeta los clics modificados/no primarios, usa sendBeacon para las navegaciones y mantén la analítica como de mejor esfuerzo para que jamás pueda romper un enlace.
  • En la era de los Server Components, la delegación es lo que evita que la analítica convierta secciones estáticas en islas hidratadas.

Ninguna de las piezas de aquí es exótica: el burbujeo de eventos, closest(), los atributos data-*, un import() diferido y sendBeacon son todas características de plataforma aburridas y bien soportadas. El cambio está en dónde pones el comportamiento. Deja de enviar un manejador de clic con cada componente, envía un listener para toda la página, y deja que tus componentes vuelvan a ser lo que mejor renderizan — HTML.