We’ve migrated our documentation to a new site, which means some URLs have changed. If you hit a broken link, submit a support ticket.
Subscriptions
English French
English French

External events

LIGHTWEIGHT TEMPLATES

Not to be confused with Events, which cover DOM handlers inside a template (onClick, onInput, etc.). This page is about events the template sends out of the widget.

What an external event is

An external event is a named notification a template sends out of the widget when the user interacts with it, optionally carrying a bag of parameters. Firing one does two things:

  • logs an auto micro conversion with event type EXTERNAL_EVENT and the event name as its event group id; this is what appears in the Conversion Report;

  • calls the publisher's customEvent handler on the host page with the event name and the params object.

JavaScript
tp.push(["addHandler", "customEvent", function (event) {
  event.eventName; // the name you passed
  event.params;    // the params object you passed
}]);

This is the same customEvent handler classic templates used, so an existing publisher integration keeps receiving events after a migration. What changes is the shape of params; see Coming from classic templates below.

What can fire one

Mechanism

Use it when

Documented in

PianoExternalEvent

you need to fire an event from arbitrary markup, a plain <button>, a link, a form's submit button, a whole card

Components → PianoExternalEvent

externalEventName + externalEventParams on a Piano button

you are already using a Piano button component

Components → Buttons

Both are current, and both are the same mechanism underneath. Pick whichever fits the markup you already have. There is no third way: the classic external-event-* HTML attributes do nothing in a Lightweight template, whether written in the JSX tab or set from the JS tab with setAttribute().

Buttons that accept externalEventName / externalEventParams

PianoExternalEvent

Attribute name

type

required / optional

default value

description

name

string

required


external event name

params

object

optional

{}

parameters sent with the event

children

JSX.Element

required


the markup whose clicks fire the event

class / id / classList / style

N/A

optional


applied to the wrapper element

PianoExternalEvent renders a wrapper <div> around its children and listens for clicks on it in the capture phase. Two consequences worth knowing up front:

Any click inside the wrapper fires the event, not only a click on the button; keep the wrapper tight around the element you mean.

React JSX
<PianoExternalEvent name="cta-click" style={{ display: "contents" }}>
  <button type="submit">Continue</button>
</PianoExternalEvent>

Example 1: static parameters

Everything is known at authoring time, so only the JSX tab is involved.

React JSX
<PianoExternalEvent
  name="cta-click"
  params={{ placement: "sidebar", campaign: "[%% campaign_id %%]" }}
>
  <button type="button">Continue</button>
</PianoExternalEvent>

Example 2: parameters read from the form at click time

This is the case that needs both tabs. The JSX tab declares the markup and the params; the JS tab declares the variables the markup binds to and any helpers the params call. Two kinds of binding appear here: ref, when the value is read straight off the DOM at fire time, and a signal, when the template has to render the value as well.

JS tab

JavaScript
// read off the DOM at fire time
let comment;
let topicA;
let topicB;

// a signal, because the subject is rendered as well as sent
const [subject, setSubject] = createSignal("");

const selectedTopics = () =>
  [
    topicA?.checked ? "[%% topic_a_id %%]" : "",
    topicB?.checked ? "[%% topic_b_id %%]" : "",
  ]
    .filter(Boolean)
    .join(",");

JSX tab

React JSX
<form>
  <label for="comment">Comment</label>
  <input id="comment" type="text" ref={comment} required />

  <label>
    <input type="checkbox" ref={topicA} /> Topic A
  </label>
  <label>
    <input type="checkbox" ref={topicB} /> Topic B
  </label>

  <label for="subject">Subject</label>
  <input
    id="subject"
    type="text"
    maxlength="60"
    onInput={(e) => setSubject(e.currentTarget.value)}
  />
  <p>{subject().length} / 60</p>

  <PianoExternalEvent
    name="[%% event_name %%]"
    params={{
      comment: comment?.value,
      topicIds: selectedTopics(),
      subject: subject(),
      formId: "[%% form_id %%]",
    }}
    style={{ display: "contents" }}
  >
    <button type="submit">Send</button>
  </PianoExternalEvent>
</form>

How the two tabs connect

  1. The JS tab is the component body. Your JS tab and the JSX tab are compiled into the same function, in that order. Anything you declare at the top level of the JS tab is visible to the JSX; anything you hide inside an IIFE is not. FAQ: How does the JS tab interact with the JSX tab?

    JavaScript
    // ✅ visible to the JSX
    let comment;
    
    // ❌ invisible to the JSX
    (function () {
      let comment;
    })();
    
  2. ref={x} assigns the DOM element to x. It is a plain assignment, not a signal setter, so x must be declared with let or var at the top level of the JS tab. const throws at render time; an undeclared name is a silent global. FAQ: What is ref and how do I use it? and Which variables from the JS tab can the JSX see, and how do I declare them?

  3. A signal is the other option, and the one to pick when the value also drives rendering; the character count above cannot be written with a ref. createSignal and the rest of the Solid primitives are already in scope in the JS tab, so no import is needed.

  4. params is evaluated when the event fires, not when the template renders. That is what makes comment?.value, subject() and selectedTopics() return what the user actually typed and picked. You do not need to mirror form state into variables on every onChange; read it inside params.

  5. [%% field %%] content fields are substituted in the JS tab too. Substitution runs over all three tabs, HTML/JSX, CSS and JS, before the template reaches the browser, and the editor offers content-field autocompletion in all three. FAQ: Are content fields substituted in the JS tab too?

Buttons

A Piano button fires the event on its own click, before your onClick runs. No wrapper element is added.

React JSX
<PianoPrimaryButton
  onClick={(e) => context.login()}
  externalEventName="header-login"
  externalEventParams={{ placement: "header" }}
>
  <T>Log in</T>
</PianoPrimaryButton>

Runtime parameters work exactly as they do for PianoExternalEvent; externalEventParams is evaluated at click time, so it can read live DOM values bound with ref:

JS tab

JavaScript
let promoCode;

JSX tab

React JSX
<input type="text" ref={promoCode} placeholder="Promo code" />

<PianoPrimaryButton
  onClick={(e) => context.startCheckout({ term: item })}
  externalEventName="promo-applied"
  externalEventParams={{ code: promoCode?.value, termId: item.termId }}
>
  <T>Subscribe</T>
</PianoPrimaryButton>

Note. The example above uses PianoPrimaryButton, but externalEventName and externalEventParams behave identically on every button listed above: PianoPrimaryButton, PianoSecondaryButton, PianoGhostButton, PianoIconButton, PianoCloseButton / CloseButton, PianoBackButton, PianoStartCheckoutButton and PianoBuyAsAGiftButton.
Two of them add a parameter of their own: PianoStartCheckoutButton and PianoBuyAsAGiftButton merge termId (taken from their term prop) into externalEventParams, so you do not have to pass it yourself.

Coming from classic templates

In a classic (AngularJS) template, an external event was an attribute directive:

HTML
<button external-event="cta-click"
        external-event-event-label="sidebar"
        external-event-plan="{{ selectedPlan }}">
  Continue
</button>

The classic runtime read every attribute whose name began with external-event-, turned the dash-separated suffix into a camelCase key with a lowercased first letter, and used the attribute's (interpolated) string value:

Classic attribute

params key

external-event-href

href

external-event-event-label

eventLabel

external-event-argument-1

argument1

external-event-clicktext

clicktext

Because these were HTML attributes, every classic parameter was a string, and the browser lowercases attribute names, so external-event-eventLabel produced the key eventlabel, not eventLabel. In a Lightweight template, params is a real JavaScript object: keys keep their case, and values keep their types.

The classic directive also fires on submit as well as click, on the element itself. PianoExternalEvent listens for click only, on its wrapper. Clicking a submit button (including implicit submission by pressing Enter in a field) still fires it; a form submitted from JavaScript does not.

Mapping table

Every classic form with its Lightweight equivalent, written out as code. Both Lightweight columns do the same thing: the PianoExternalEvent wrapper in one, the externalEventName / externalEventParams props on a Piano button in the other; pick whichever fits the markup you already have. Rows about a single attribute show only the lines that differ, and where a row needs template-side state, the JS tab snippet follows the JSX one.

Classic (piano-vx)

Lightweight PianoExternalEvent equivalent

Lightweight Button ExternalEvent equivalent

HTML
<button external-event="cta-click">
  Continue
</button>
React JSX
<PianoExternalEvent name="cta-click">
  <button type="button"><T>Continue</T></button>
</PianoExternalEvent>
React JSX
{/* onClick is required even when the
    event is all you want */}
<PianoPrimaryButton
  onClick={() => {}}
  externalEventName="cta-click"
>
  <T>Continue</T>
</PianoPrimaryButton>
HTML
<button external-event="cta-click"
        external-event-placement="sidebar">
React JSX
<PianoExternalEvent
  name="cta-click"
  params={{ placement: "sidebar" }}
>
React JSX
<PianoPrimaryButton
  onClick={() => {}}
  externalEventName="cta-click"
  externalEventParams={{ placement: "sidebar" }}
>
HTML
<!-- HTML lowercases attribute names,
     so write dashes, never
     external-event-eventLabel -->
external-event-event-label="hero"
React JSX
{/* the key keeps its camelCase */}
params={{ eventLabel: "hero" }}
React JSX
externalEventParams={{ eventLabel: "hero" }}
HTML
<!-- digits pass through:
     argument-1 becomes argument1 -->
external-event-argument-1="42"
React JSX
params={{ argument1: "42" }}
React JSX
externalEventParams={{ argument1: "42" }}
HTML
<!-- one attribute per parameter -->
external-event="cta-click"
external-event-placement="sidebar"
external-event-event-label="hero"
React JSX
{/* one object instead of N attributes */}
params={{
  placement: "sidebar",
  eventLabel: "hero",
}}
React JSX
externalEventParams={{
  placement: "sidebar",
  eventLabel: "hero",
}}
HTML
<!-- a value the widget already had -->
external-event-app="{{ app.name }}"
React JSX
params={{ appName: context?.app?.name }}
React JSX
externalEventParams={{ appName: context?.app?.name }}
HTML
external-event="[%% event_name %%]"
external-event-campaign="[%% campaign_id %%]"
React JSX
name="[%% event_name %%]"
params={{ campaign: "[%% campaign_id %%]" }}
React JSX
externalEventName="[%% event_name %%]"
externalEventParams={{ campaign: "[%% campaign_id %%]" }}
HTML
<!-- value the user typed, via ng-model -->
<input ng-model="code">
<button external-event="promo"
        external-event-code="{{ code }}">
React JSX
<input type="text" ref={code} />

<PianoExternalEvent
  name="promo"
  params={{ code: code?.value }}
>
  <button type="button"><T>Apply</T></button>
</PianoExternalEvent>
JavaScript
// JS tab — ref assigns the element itself,
// so the value is read off the DOM on click
let code;
React JSX
<input type="text" ref={code} />

<PianoPrimaryButton
  onClick={() => {}}
  externalEventName="promo"
  externalEventParams={{ code: code?.value }}
>
  <T>Apply</T>
</PianoPrimaryButton>
JavaScript
// JS tab
let code;
HTML
<!-- same thing; the scope held the value
     and other markup could read it too -->
<input ng-model="code">
<span>{{ code }}</span>
<button external-event="promo"
        external-event-code="{{ code }}">
React JSX
{/* no ref: a signal holds the value, so the
    rest of the template can render it too */}
<input type="text" onInput={(e) => setCode(e.currentTarget.value)} />
<span>{code()}</span>

<PianoExternalEvent
  name="promo"
  params={{ code: code() }}
>
  <button type="button"><T>Apply</T></button>
</PianoExternalEvent>
JavaScript
// JS tab — createSignal is already in scope,
// no import needed
const [code, setCode] = createSignal("");
React JSX
<input type="text" onInput={(e) => setCode(e.currentTarget.value)} />

<PianoPrimaryButton
  onClick={() => {}}
  externalEventName="promo"
  externalEventParams={{ code: code() }}
>
  <T>Apply</T>
</PianoPrimaryButton>
JavaScript
// JS tab
const [code, setCode] = createSignal("");
HTML
<!-- conditional inside the interpolation -->
external-event-plan="{{ isAnnual ? 'annual' : 'monthly' }}"
React JSX
params={{ plan: plan() }}
JavaScript
// JS tab — a ternary written straight into
// params compiles to a function, not a value,
// so keep it here
let isAnnual;
const plan = () =>
  isAnnual?.checked ? "annual" : "monthly";
React JSX
externalEventParams={{ plan: plan() }}
JavaScript
// JS tab
let isAnnual;
const plan = () =>
  isAnnual?.checked ? "annual" : "monthly";
HTML
<!-- legacy object bag -->
external-event-params="{{ { name: app.name, url: app.url } }}"
React JSX
{/* the bag's keys become ordinary keys */}
params={{
  name: context?.app?.name,
  url: context?.app?.url,
}}
React JSX
externalEventParams={{
  name: context?.app?.name,
  url: context?.app?.url,
}}
HTML
<!-- a NON-object value stayed under the
     literal key "params" -->
external-event-params="{{ app.url }}"
React JSX
params={{ params: context?.app?.url }}
React JSX
externalEventParams={{ params: context?.app?.url }}
HTML
<!-- empty element, label came from the
     attribute -->
<div external-event="cta"
     external-event-clicktext="Read more"></div>
React JSX
{/* label goes into children; keep the
    parameter if the publisher reads it */}
<PianoExternalEvent
  name="cta"
  params={{ clicktext: "Read more" }}
>
  <button type="button"><T>Read more</T></button>
</PianoExternalEvent>
React JSX
<PianoPrimaryButton
  onClick={() => {}}
  externalEventName="cta"
  externalEventParams={{ clicktext: "Read more" }}
>
  <T>Read more</T>
</PianoPrimaryButton>
HTML
<!-- the publisher's handler did the
     navigation -->
<div external-event="banner"
     external-event-href="[%% target_url %%]">
  Read more
</div>
React JSX
{/* the wrapper never navigates — <A> does,
    and tracks the link as well */}
<PianoExternalEvent
  name="banner"
  params={{ href: "[%% target_url %%]" }}
>
  <A href="[%% target_url %%]"><T>Read more</T></A>
</PianoExternalEvent>

Not applicable; a button does not navigate. Use the wrapper with <A>.

HTML
<!-- classic fired on click AND submit -->
<form external-event="signup-submit">
  <button type="submit">Send</button>
</form>
React JSX
{/* the wrapper listens for click only —
    put it around the submit button */}
<form>
  <PianoExternalEvent
    name="signup-submit"
    style={{ display: "contents" }}
  >
    <button type="submit"><T>Send</T></button>
  </PianoExternalEvent>
</form>
React JSX
{/* Piano buttons render type="button" and
    never submit a form — do it in onClick */}
<PianoPrimaryButton
  onClick={() => sendForm()}
  externalEventName="signup-submit"
>
  <T>Send</T>
</PianoPrimaryButton>
HTML
<!-- custom-script, at runtime -->
btn.setAttribute('external-event-code', code);
React JSX
{/* not supported: parameters come from the
    prop, which is read at click time */}
params={{ code: code?.value }}
JavaScript
// JS tab
let code;
React JSX
externalEventParams={{ code: code?.value }}
JavaScript
// JS tab
let code;
HTML
<!-- the attribute only notified;
     ng-click did the closing -->
<button ng-click="close()"
        external-event="close">×</button>
React JSX
<PianoExternalEvent name="close">
  <button
    type="button"
    onClick={() => context.close()}
  >×</button>
</PianoExternalEvent>
React JSX
{/* it already closes the widget */}
<PianoCloseButton externalEventName="close" />
HTML
<!-- the attribute only notified;
     ng-click started the login flow -->
<button ng-click="login()"
        external-event="login">
  Log in
</button>
React JSX
{/* same split: the wrapper notifies,
    the context call acts */}
<PianoExternalEvent name="login">
  <button
    type="button"
    onClick={() => context.login()}
  ><T>Log in</T></button>
</PianoExternalEvent>
React JSX
<PianoPrimaryButton
  onClick={() => context.login()}
  externalEventName="login"
>
  <T>Log in</T>
</PianoPrimaryButton>
HTML
<!-- the attribute only notified;
     ng-click started the checkout -->
<button ng-click="startCheckout(term)"
        external-event="startCheckout"
        external-event-term-id="{{ term.termId }}">
  Subscribe
</button>
React JSX
<PianoExternalEvent
  name="startCheckout"
  params={{ termId: item.termId }}
>
  <button
    type="button"
    onClick={() => context.startCheckout({ term: item })}
  ><T>Subscribe</T></button>
</PianoExternalEvent>
React JSX
{/* termId is merged into the params for you */}
<PianoStartCheckoutButton
  term={item}
  externalEventName="startCheckout"
>
  <T>Subscribe</T>
</PianoStartCheckoutButton>

Gotchas

  • A parameter expression that throws swallows the event. comment.value where comment was never bound (declared inside an IIFE, or the input is inside a <Show> that has not rendered) throws at click time; the click still does whatever else it does, but no event and no conversion are recorded. Use optional chaining comment?.value inside params.

  • params is not reactive state. It is read once per fire. Do not expect a createEffect to re-send anything.

  • Wrapper vs. button. If both PianoExternalEvent and a button's externalEventName are present around/on the same element, two events fire. Pick one.


Last updated: