Code snippets

How to Track Clicks on A/B Test Elements with JavaScript

★★★ Technical level 3 of 3

Written by Neil Webley · Last updated

Attach JavaScript click handlers once, prevent duplicate events and use event delegation for dynamically rendered SPA controls.

Quick answer

Use addEventListener for an element that already exists, event delegation for controls an SPA may replace, and an installation guard or AbortController so setup cannot attach the same handler twice. Track the action inside the handler, not when the experiment loads.

How do I attach a JavaScript click handler?

var button = document.querySelector('[data-test="pricing-cta"]');

if (button) {
  button.addEventListener('click', function (event) {
    console.log('Pricing CTA clicked', event.currentTarget);
  });
}

Use currentTarget for the element that owns the listener; target may be an icon or span inside it. Check for null, and prefer a stable data attribute or component class over visible text.

How do I stop a click handler being attached twice?

The once option removes a listener after its first click. It does not prevent two copies being installed during setup. Use it only when one activation is the intended behaviour.

button.addEventListener('click', handleClick, { once: true });

For a listener that should remain active but be installed once, use a test-specific guard:

if (!window.__fcroPricingHandlerInstalled) {
  window.__fcroPricingHandlerInstalled = true;
  document.addEventListener('click', handlePricingClick);
}

When cleanup is required, AbortController provides a clear lifecycle:

var controller = new AbortController();

button.addEventListener('click', handleClick, {
  signal: controller.signal
});

// Later:
controller.abort();

Attach a handler once for SPA elements

Event delegation places one listener on a stable ancestor and finds the relevant control after the event bubbles. It survives node replacement without a MutationObserver.

if (!window.__fcroPricingDelegationInstalled) {
  window.__fcroPricingDelegationInstalled = true;

  document.addEventListener('click', function (event) {
    var button = event.target.closest('[data-test="pricing-cta"]');
    if (!button) return;

    cro_tests.fireClickEvent({
      name: 'pricing_cta_clicked'
    });
  });
}

If the late-rendered element is one-shot and will not be replaced, cro_tests.waitForElement() can locate it before using cro_tests.addListener(). For repeatable SPA controls, delegation is usually simpler and safer.

Track a click in a FreeCROTool hand-coded test

cro_tests.addListener(
  '[data-test="static-pricing-cta"]',
  'click',
  function () {
    cro_tests.fireClickEvent({
      name: 'pricing_cta_clicked'
    });
  }
);

addListener queries selector strings immediately and does not deduplicate handlers or watch for future nodes. fireClickEvent is processed at publish time so the test ID is added automatically. Code in the project's main-tag initialisation script must provide the real ID explicitly.

Use a stable event name and do not change it midway through a test. Read the broader GA4 and GTM click-tracking guide and the FreeCROTool SPA helper reference.

Preserve keyboard and navigation behaviour

  • Prefer native button and a elements.
  • Avoid replacing normal link navigation merely to track a click.
  • Do not call preventDefault() unless the experiment deliberately owns the resulting action.
  • If a non-native control is unavoidable, implement keyboard activation, focus and an accessible name.
  • Use passive listeners for scroll/touch monitoring when the handler never cancels the event.
  • Test nested icons and text so closest() finds the intended control.

Finally, distinguish a click from the business outcome. A checkout click may explain behaviour, but a confirmed purchase or revenue event is normally the stronger KPI.

© 2026 FreeCROTool. All rights reserved | FAQ | site map | Terms and conditions