Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

jQuery 4 (legacy code and migration to DOM APIs)  Module 3 – Events, timing and effects

trigger(), custom events and native listeners

Which listeners .trigger(), .triggerHandler() and dispatchEvent() reach, why native code misses a triggered change, and how to publish events both hear.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, jQuery 4.0.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Predict which handlers and listeners .trigger(), .triggerHandler(), dispatchEvent() and an element's own method reach
  • Pass extra parameters with .trigger() and use the value that .triggerHandler() returns
  • Fix a jQuery plugin whose triggered change never reaches listeners added with addEventListener()
  • Replace .trigger('submit') with requestSubmit() where submit listeners and validation must run
  • Publish a custom event as a bubbling CustomEvent that jQuery handlers and native listeners both receive

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict which handlers and listeners .trigger(), .triggerHandler(), dispatchEvent() and an element's own method reach
  • Pass extra parameters with .trigger() and use the value that .triggerHandler() returns
  • Fix a jQuery plugin whose triggered change never reaches listeners added with addEventListener()
  • Replace .trigger('submit') with requestSubmit() where submit listeners and validation must run
  • Publish a custom event as a bubbling CustomEvent that jQuery handlers and native listeners both receive

Before you start

On this page

An element can be told about an event in four ways, .trigger(), .triggerHandler(), dispatchEvent() and its own method such as click(), and in the recordings below only dispatchEvent() reached every kind of listener in every case. A day picker written as a jQuery plugin shows why that matters: it announces each chosen day with .trigger('change'), the checkout was rewritten with addEventListener(), and the delivery charge never appears.

These pages ran in jsdom on jQuery 4.0.0 and 3.7.1, which logged the same lines, and each one notes every handler and listener that heard each event, in the order they heard it. You will predict who hears what, pass extra parameters, fix the picker, send forms with requestSubmit(), and publish custom events that jQuery and native code both receive.

.trigger() runs jQuery’s handlers, then the element’s own method

.trigger(type) does not dispatch a browser event. It makes a jQuery event object, runs the handlers jQuery has stored for that type on the element and on each of its ancestors, calls the on… property of each, such as onclick, and then, unless a handler prevented the default action: What the browser itself does after an event's listeners have run, such as following a clicked link, ticking a checkbox or sending a form. A listener cancels it with event.preventDefault(). More in “jQuery events: on(), off(), one() and delegation”, calls the element’s own method of that name if it has one (.trigger()). Only that last step can produce an event that native listeners hear. Step through two triggers:

Interactive · step by step What .trigger() does, step by step Step through .trigger('change') on the delivery slot, then .trigger('click') on the Pay button.
What .trigger() does, step by step, step 7 of 7. Showing A jQuery event is made, jQuery handlers run (button, then #wrap), on… properties run (none set), Has el a method of, jQuery calls it (link: never; checkbox: first), Nothing more happens (change ends here). In focus: jQuery calls it.yesnoA jQuery event is madewith any extra parametersjQuery handlers runbutton, then #wrapon… properties runnone setHas el a method ofthat name, such as click()?jQuery calls itlink: never; checkbox: firstNothing more happenschange ends here

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. change on the slot. .trigger('change') on the delivery slot starts by making a jQuery event object. No browser event exists yet: nothing has been dispatched.
  2. jQuery's handlers. jQuery runs the handlers it stored for change, on the select and then on #wrap around it, walking the path itself. It calls only what it stored, so no listener added with addEventListener() runs.
  3. on… properties. On the way it also calls the onchange property of each element that has one. The recording logged it after the select's jQuery handler.
  4. No change() method. Then it looks for a method called change on the select. Elements have none, so nothing more happens, and the native listeners on the select and on #wrap never heard the event.
  5. click on Pay. .trigger('click') on the Pay button goes through the same first steps: the jQuery handlers on the button and on #wrap run first.
  6. click() exists. Elements do have a click() method, so jQuery calls it. That dispatches a real click, which the native listeners on the button and on #wrap now hear; jQuery skips its own handlers this time, because they have run.
  7. Two exceptions. On a link jQuery never calls click(), so the link is not followed. On a checkbox, and for focus, it calls the method first, so the box is ticked or the field focused before the handlers run.

The actions page puts a jQuery handler and a native listener on a button, a checkbox, a link and a field, and on #wrap around them, then fires a click or a focus each of the four ways. record-hits.js notes who heard each one, in the order they heard it:

The actions page, record-hits.js and fire-actions.js

shop/fire-actions.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Four ways to fire a click or a focus</title>
  </head>
  <body>
    <div id="wrap">
      <button type="button" id="pay">Pay</button>
      <label><input type="checkbox" id="gift"> Gift wrap</label>
      <a href="#offers" id="offers-link">Offers</a>
      <label for="pin">PIN code</label>
      <input id="pin">
    </div>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="record-hits.js"></script>
    <script src="fire-actions.js"></script>
  </body>
</html>

shop/record-hits.js

// record-hits.js: notes which listeners hear an event. listen(el, type) puts a jQuery handler and a native listener on
// the element and, once per event type, on #wrap around it. fire(label, how, after, wait) clears the notes, fires the
// event the way how() does, waits for wait() when one is given, and logs who heard it, in the order they heard it,
// with what after() reports.
const hits = [];
const onWrap = new Set();
function listen(el, type) {
  const note = (who) => () => hits.push(who);
  $(el).on(type, note('jQuery'));
  el.addEventListener(type, note('native'));
  if (onWrap.has(type)) return;
  onWrap.add(type);
  const wrap = document.getElementById('wrap');
  $(wrap).on(type, note('jQuery on #wrap'));
  wrap.addEventListener(type, note('native on #wrap'));
}
function report(label, extra) {
  console.log(`${label}: ${hits.length ? hits.join(', ') : 'nobody'}${extra ? `; ${extra}` : ''}`);
}
function fire(label, how, after = () => '', wait) {
  hits.length = 0;
  how();
  if (!wait) return report(label, after());
  return wait().then(() => report(label, after()));
}

shop/fire-actions.js

// fire-actions.js: a click on a button, a checkbox and a link, and focus on a field, each fired four ways: .trigger(),
// .triggerHandler(), dispatchEvent() of a real event, and the element's own method, with what changed on the page.
// A followed link changes the address a moment later, so each firing waits 20 ms before it reports.
const pay = document.getElementById('pay');
const gift = document.getElementById('gift');
const link = document.getElementById('offers-link');
const pin = document.getElementById('pin');
for (const el of [pay, gift, link]) listen(el, 'click');
listen(pin, 'focus');

const click = () => new MouseEvent('click', { bubbles: true, cancelable: true });
const pause = () => new Promise((done) => setTimeout(done, 20));
window.addEventListener('hashchange', () => hits.push(`the link was followed to ${location.hash}`));
const ticked = () => `gift wrap ticked: ${gift.checked}`;
const followed = () => {
  history.replaceState(null, '', location.pathname);
  return '';
};
const focused = () => {
  const now = document.activeElement === pin;
  pin.blur();
  return `#pin has focus: ${now}`;
};

(async function () {
  for (const [el, what, after] of [
    [pay, 'click on the button', () => ''],
    [gift, 'click on the checkbox', ticked],
    [link, 'click on the link', followed],
  ]) {
    for (const [how, run] of [
      ['.trigger()', () => $(el).trigger('click')],
      ['.triggerHandler()', () => $(el).triggerHandler('click')],
      ['dispatchEvent()', () => el.dispatchEvent(click())],
      ['el.click()', () => el.click()],
    ]) {
      await fire(`${what} by ${how}`, run, after, pause);
    }
  }
  for (const [how, run] of [
    ['.trigger()', () => $(pin).trigger('focus')],
    ['.triggerHandler()', () => $(pin).triggerHandler('focus')],
    ['dispatchEvent()', () => pin.dispatchEvent(new FocusEvent('focus'))],
    ['el.focus()', () => pin.focus()],
  ]) {
    await fire(`focus by ${how}`, run, focused, pause);
  }
})();
Who heard each click and focus JavaScript · fire-actions.mjs
// Prints what shop/fire-actions.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/fire-actions.html');

Output

click on the button by .trigger(): jQuery, jQuery on #wrap, native, native on #wrap
click on the button by .triggerHandler(): jQuery
click on the button by dispatchEvent(): jQuery, native, jQuery on #wrap, native on #wrap
click on the button by el.click(): jQuery, native, jQuery on #wrap, native on #wrap
click on the checkbox by .trigger(): jQuery, jQuery on #wrap, native; gift wrap ticked: true
click on the checkbox by .triggerHandler(): jQuery; gift wrap ticked: true
click on the checkbox by dispatchEvent(): jQuery, native, jQuery on #wrap, native on #wrap; gift wrap ticked: false
click on the checkbox by el.click(): jQuery, native, jQuery on #wrap, native on #wrap; gift wrap ticked: true
click on the link by .trigger(): jQuery, jQuery on #wrap
click on the link by .triggerHandler(): jQuery
click on the link by dispatchEvent(): jQuery, native, jQuery on #wrap, native on #wrap, the link was followed to #offers
click on the link by el.click(): jQuery, native, jQuery on #wrap, native on #wrap, the link was followed to #offers
focus by .trigger(): jQuery, native; #pin has focus: true
focus by .triggerHandler(): jQuery; #pin has focus: false
focus by dispatchEvent(): jQuery, native; #pin has focus: false
focus by el.focus(): jQuery, native; #pin has focus: true
(jQuery 3.7.1 logged the same 16 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node fire-actions.mjs

The button shows the rule. .trigger('click') ran both jQuery handlers first, the button’s and then #wrap’s, and only then called pay.click(), whose real click reached the two native listeners, while a real click from dispatchEvent() or el.click() reached each element’s listeners in turn. Three cases differ. .trigger() never followed the link, because jQuery does not call click() on links. It ticked the checkbox, but the native listener on #wrap missed that click: for a checkbox or a radio button jQuery calls click() first, then stops the real click at the box. And it moved the focus, because jQuery calls focus(), while a dispatched focus event ran the listeners and left the focus where it was.

Fired with Who heard it What the page did
.trigger() jQuery’s handlers first, then native ones through the method ticked, focused; never followed a link
.triggerHandler() the first element’s jQuery handlers nothing
dispatchEvent() everyone on the path, in turn toggled the box, followed; never moved the focus
el.click(), el.focus() everyone on the path, in turn what a reader’s click or tab does

Ticking a box with .trigger('click')

Wrong: $(box).trigger('click') in a form that saves every change from one native click listener on a fieldset, which never hears this click. Right: box.click(), which every listener hears, or a listener for change, which the box fires on its own when it is ticked.

Checkpoint

Check: What .trigger() runs, and in what order

  1. Question 1 of 2 The Pay button has a jQuery handler and a native listener, and so does #wrap around it. In which order did they run for $(pay).trigger('click')?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: jQuery on the button, jQuery on #wrap, native on the button, native on #wrap

    .trigger() runs the jQuery handlers along the path, then the element's method of the same name if it has one. Only that method produces an event the native listeners hear.

  2. Question 2 of 2 What does $(link).trigger('click') do to a link with href="#offers"?
    Show the answer: Question 2 of 2

    Answer: Runs the jQuery click handlers and does not follow the link

    To follow a link from code, set location or call link.click(); .trigger('click') only runs the jQuery handlers.

A triggered change or custom event never reaches native listeners

change, input and names such as cart:updated have no method on the element, so .trigger() stops after jQuery’s handlers and the on… properties. The data page fires each of them the three ways it can be fired:

The data page and fire-data.js

shop/fire-data.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Four ways to fire an event</title>
  </head>
  <body>
    <div id="wrap">
      <div id="cart" data-count="2">Cart: 2 items</div>
      <label for="slot">Delivery slot</label>
      <select id="slot"><option>Morning</option><option>Evening</option></select>
      <label for="note">Note</label>
      <input id="note">
    </div>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="record-hits.js"></script>
    <script src="fire-data.js"></script>
  </body>
</html>

shop/fire-data.js

// fire-data.js: a custom event, change and input, each fired three ways: .trigger(), .triggerHandler() and
// dispatchEvent() of a real event that bubbles. None of the three has a method of its own on the element.
const cart = document.getElementById('cart');
const slot = document.getElementById('slot');
const note = document.getElementById('note');
listen(cart, 'cart:updated');
listen(slot, 'change');
slot.onchange = () => hits.push('onchange property');
listen(note, 'input');

for (const [el, type, make] of [
  [cart, 'cart:updated', () => new CustomEvent('cart:updated', { bubbles: true, detail: { count: 3 } })],
  [slot, 'change', () => new Event('change', { bubbles: true })],
  [note, 'input', () => new Event('input', { bubbles: true })],
]) {
  fire(`${type} by .trigger()`, () => $(el).trigger(type));
  fire(`${type} by .triggerHandler()`, () => $(el).triggerHandler(type));
  fire(`${type} by dispatchEvent()`, () => el.dispatchEvent(make()));
}
console.log(`methods of their own: cart['cart:updated'] ${typeof cart['cart:updated']}, slot.change ${typeof slot.change}, note.input ${typeof note.input}`);

// Changing a value from code fires nothing, with jQuery or without.
fire(".val('Evening')", () => $(slot).val('Evening'), () => `the value is now ${slot.value}`);
fire("note.value = 'Ring twice'", () => {
  note.value = 'Ring twice';
}, () => `the value is now ${note.value}`);
Who heard each change, input and custom event JavaScript · fire-data.mjs
// Prints what shop/fire-data.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/fire-data.html');

Output

cart:updated by .trigger(): jQuery, jQuery on #wrap
cart:updated by .triggerHandler(): jQuery
cart:updated by dispatchEvent(): jQuery, native, jQuery on #wrap, native on #wrap
change by .trigger(): jQuery, onchange property, jQuery on #wrap
change by .triggerHandler(): jQuery, onchange property
change by dispatchEvent(): jQuery, native, onchange property, jQuery on #wrap, native on #wrap
input by .trigger(): jQuery, jQuery on #wrap
input by .triggerHandler(): jQuery
input by dispatchEvent(): jQuery, native, jQuery on #wrap, native on #wrap
methods of their own: cart['cart:updated'] undefined, slot.change undefined, note.input undefined
.val('Evening'): nobody; the value is now Evening
note.value = 'Ring twice': nobody; the value is now Ring twice
(jQuery 3.7.1 logged the same 12 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node fire-data.mjs

For all three, .trigger() reached jQuery’s handlers on the element and on #wrap, and for change the onchange property, but not one native listener; a real event that bubbles reached everyone. .val('Evening') and note.value = … fired nothing at all, with jQuery or without: code that sets a value has to announce it itself. That is the day picker’s bug. Its plugin writes the day with .val() and announces it with .trigger('change'), while the checkout listens on the form with addEventListener(). Step through a click on a day with the old plugin, then with the fixed one:

Interactive · message by message A jQuery day picker and a checkout written without jQuery Step through a click on a day, first with the old plugin and then with the fixed one, and watch the delivery line.
A jQuery day picker and a checkout written without jQuery, step 6 of 6. Messages so far: Day picker to Day field: .val('tue'); Day picker to Day field: .trigger('change'); Day picker to Day field: input, bubbles: true; Day field to Checkout: input bubbles up; Day picker to Day field: change, bubbles: true.Day pickera jQuery pluginDay fieldjQuery handler ranCheckoutshows the charge.val('tue').trigger('change')input, bubbles: trueinput bubbles upchange, bubbles: true

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The old plugin writes. The reader clicks Tuesday. The old plugin writes tue into the field with .val(), which fires no event at all.
  2. It triggers change. .trigger('change') runs the jQuery change handlers of the field and its ancestors. The page's old handler logs the new day.
  3. The checkout is not told. The checkout listens on the form with addEventListener(). jQuery never calls it, so the delivery line still says choose a day.
  4. The fixed plugin. The fixed plugin sets field.value and dispatches a real input event that bubbles, as typing would.
  5. The checkout hears it. The input event bubbles from the field to the form, where the checkout's listener shows the delivery charge for Wednesday.
  6. Then change. A real change event follows. The old jQuery change handler hears it too, so code of both kinds keeps working.

The checkout, checkout.js and the old picker.js

shop/picker.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Delivery day</title>
  </head>
  <body>
    <form id="checkout">
      <label for="date">Delivery day</label>
      <input id="date" name="date" value="">
      <div id="calendar">
        <button type="button" data-day="tue">Tuesday</button>
        <button type="button" data-day="wed">Wednesday</button>
      </div>
      <p id="charge">Delivery: choose a day</p>
    </form>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="checkout.js"></script>
    <script src="picker.js"></script>
  </body>
</html>

shop/checkout.js

// checkout.js: the checkout's newer code, written without jQuery. It shows the delivery charge for the chosen day,
// listening on the form for the input and change events that the day field sends.
const CHARGE = { tue: '₹49', wed: '₹79' };
const form = document.getElementById('checkout');
function showCharge(event) {
  const day = form.elements.date.value;
  document.getElementById('charge').textContent = `Delivery: ${CHARGE[day] ?? 'choose a day'}`;
  console.log(`checkout, native ${event.type} listener: ${document.getElementById('charge').textContent}`);
}
form.addEventListener('input', showCharge);
form.addEventListener('change', showCharge);

shop/picker.js

// picker.js: a small jQuery day picker, written the way many old plugins are. A click on a day writes it into the
// field with .val() and announces it with .trigger('change').
$.fn.dayPicker = function () {
  const $field = this;
  $('#calendar').on('click', 'button', function () {
    $field.val(this.dataset.day).trigger('change');
  });
  return this;
};
$('#date').dayPicker();

// Older page code listens with jQuery.
$('#date').on('change', function () {
  console.log(`jQuery change handler: the field says ${this.value}`);
});
What the old picker logged JavaScript · picker.mjs
// Prints what shop/picker.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/picker.html');

Output

▸ click Tuesday in the day picker
jQuery change handler: the field says tue
▸ read the delivery line
the page shows: Delivery: choose a day
(jQuery 3.7.1 logged the same 4 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node picker.mjs

The fixed picker-fixed.js JavaScript · shop/picker-fixed.js
// picker-fixed.js: the same plugin, announcing the new value with real events that bubble, as a reader's typing
// would: input, then change. jQuery handlers and native listeners both hear them.
$.fn.dayPicker = function () {
  const field = this[0];
  $('#calendar').on('click', 'button', function () {
    field.value = this.dataset.day;
    field.dispatchEvent(new Event('input', { bubbles: true }));
    field.dispatchEvent(new Event('change', { bubbles: true }));
  });
  return this;
};
$('#date').dayPicker();

// Older page code listens with jQuery.
$('#date').on('change', function () {
  console.log(`jQuery change handler: the field says ${this.value}`);
});
What the fixed picker logged JavaScript · picker-fixed.mjs
// Prints what shop/picker-fixed.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/picker-fixed.html');

Output

▸ click Wednesday in the day picker
checkout, native input listener: Delivery: ₹79
jQuery change handler: the field says wed
checkout, native change listener: Delivery: ₹79
▸ read the delivery line
the page shows: Delivery: ₹79
(jQuery 3.7.1 logged the same 6 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node picker-fixed.mjs

With the old plugin only the page’s jQuery handler ran, and the delivery line still asked for a day. The fixed plugin dispatches a real input and then a real change, both bubbling, as a reader’s typing would: the checkout showed Wednesday’s charge, and the old jQuery handler still ran, because a real event reaches jQuery’s handlers too.

Announce a value the way typing does

After code sets a field’s value, dispatch new Event('input', { bubbles: true }) and then new Event('change', { bubbles: true }). Native listeners hear both, and so does every jQuery handler that used to hear the triggered change.

Checkpoint

Check: Events native listeners never hear

  1. Question 1 of 2 The delivery slot has a jQuery change handler, an onchange property and a native change listener. Which of them run for $(slot).trigger('change')?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The jQuery handler and the onchange property

    A native listener, and every framework component that listens natively, hears a change only from a real event: dispatchEvent(new Event('change', { bubbles: true })).

  2. Question 2 of 2 What does $('#slot').val('Evening') fire?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No event at all

    Code that sets a value must announce it itself. The fixed day picker dispatches input and then change.

Extra parameters and triggerHandler() stay inside jQuery

Two features of .trigger() have no native form. An array after the event name becomes extra arguments of every jQuery handler, after the event object. And .triggerHandler() runs only the handlers of the first element, without bubbling and without the element’s method, and returns what the last handler returned instead of the jQuery object (.triggerHandler()). The badges page uses both:

Two badges and extras.js

shop/extras.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Extra parameters and triggerHandler()</title>
  </head>
  <body>
    <div id="header">
      <span class="badge" id="badge-top">2</span>
      <span class="badge" id="badge-side">2</span>
    </div>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="extras.js"></script>
  </body>
</html>

shop/extras.js

// extras.js: what only jQuery's own handlers receive: the extra parameters of .trigger(), and the result that
// .triggerHandler() returns.
const $badges = $('.badge');
$badges.on('cart:changed', function (event, count, source) {
  console.log(`#${this.id}: extra parameters ${count} and ${source}`);
  return Number(this.textContent) !== count; // true when the badge must be redrawn
});
$('#header').on('cart:changed', function () {
  console.log('the header heard it too');
});
document.getElementById('badge-top').addEventListener('cart:changed', function () {
  console.log('native listener on #badge-top heard it');
});

const back = $badges.trigger('cart:changed', [3, 'checkout']);
console.log(`.trigger() ran on ${$badges.length} badges and returned the jQuery object: ${back === $badges}`);

const answer = $badges.triggerHandler('cart:changed', [2, 'checkout']);
console.log(`.triggerHandler() ran on the first badge only, did not bubble, and returned ${answer}`);
What extras.js logged JavaScript · extras.mjs
// Prints what shop/extras.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/extras.html');

Output

#badge-top: extra parameters 3 and checkout
the header heard it too
#badge-side: extra parameters 3 and checkout
the header heard it too
.trigger() ran on 2 badges and returned the jQuery object: true
#badge-top: extra parameters 2 and checkout
.triggerHandler() ran on the first badge only, did not bubble, and returned false
(jQuery 3.7.1 logged the same 7 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node extras.mjs

.trigger() ran on both badges with 3 and checkout as extra parameters, bubbled to the header once for each badge, and returned the collection. .triggerHandler() ran on #badge-top only, never reached the header, and returned that handler’s false. The native listener on #badge-top heard neither of them.

Behaviour .trigger() .triggerHandler()
Elements it runs on every element of the set the first only
Bubbles yes, through jQuery’s handlers no
Calls el[type]() yes, when the element has it no
Returns the jQuery object the last handler’s value

Use .triggerHandler() to ask one element’s handlers a question, such as whether a badge must be redrawn. When code only wants to run its own click logic, the jQuery Learning Center advises against triggering at all: keep the function in a variable, attach it with .on(), and call it directly (Triggering Event Handlers).

Checkpoint

Check: Extra parameters and .triggerHandler()

  1. Question 1 of 2 Two badges each have a jQuery cart:changed handler that returns true or false. What does $badges.triggerHandler('cart:changed', [2, 'checkout']) return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The value returned by the first badge's handler

    .triggerHandler() also skips bubbling and the default action, so the header's handler did not run either.

  2. Question 2 of 2 $badges.trigger('cart:changed', [3, 'checkout']) runs. What does a native listener on #badge-top receive?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing: it never hears the event

    Extra parameters are a jQuery feature: to share data with native code, put it in a CustomEvent's detail.

.trigger(‘submit’) sends the form without its listeners or its checks

For submit, the element’s method is form.submit(), and the HTML Standard says what it skips: the form is sent “bypassing interactive constraint validation”, and no submit event is fired (HTML Standard). form.requestSubmit() runs the form’s constraint validation: The browser's check of a form's fields against their rules, such as required, pattern or type="email", before the form is sent. A failed check fires an invalid event at the field and stops the sending. and fires the event, as its submit button would. The payment page’s newer code sends the form with fetch() from a native submit listener, and its two buttons send it each way:

The payment form and submit.js

shop/submit.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Pay</title>
  </head>
  <body>
    <form id="pay" action="/pay" method="post">
      <label for="pin">PIN code</label>
      <input id="pin" name="pin" required pattern="[0-9]{6}">
      <button type="button" id="by-trigger">Pay with .trigger('submit')</button>
      <button type="button" id="by-request">Pay with requestSubmit()</button>
    </form>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="submit.js"></script>
  </body>
</html>

shop/submit.js

// submit.js: a payment form. Its newer code sends it with fetch() from a native submit listener and stays on the
// page; an older jQuery handler logs the attempt. One button sends it with .trigger('submit'), the other with
// requestSubmit().
const form = document.getElementById('pay');
const pin = form.elements.pin;
$(form).on('submit', function () {
  console.log('jQuery submit handler ran');
});
form.addEventListener('submit', function (event) {
  event.preventDefault();
  console.log('native submit listener ran: it sends the form with fetch() and the page stays');
});
pin.addEventListener('invalid', function () {
  console.log(`the PIN code field is invalid (valueMissing ${pin.validity.valueMissing}), so the form was not sent`);
});
$('#by-trigger').on('click', function () {
  $(form).trigger('submit');
});
document.getElementById('by-request').addEventListener('click', function () {
  form.requestSubmit();
});
What submit.js logged JavaScript · submit.mjs
// Prints what shop/submit.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/submit.html');

Output

▸ click Pay with .trigger('submit'), the PIN code field empty
jQuery submit handler ran
(jsdom) Not implemented: HTMLFormElement.prototype.submit
▸ click Pay with requestSubmit(), the PIN code field empty
the PIN code field is invalid (valueMissing true), so the form was not sent
▸ type 411001 into the PIN code field
▸ click Pay with requestSubmit()
jQuery submit handler ran
native submit listener ran: it sends the form with fetch() and the page stays
(jQuery 3.7.1 logged the same 9 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node submit.mjs

.trigger('submit') ran the jQuery handler and then form.submit(). jsdom stops at the point where a browser would leave for /pay, and its message names the method that asked for it. The native listener with fetch() never ran, and the empty required field stopped nothing. requestSubmit() with the field empty fired invalid and sent nothing; with a PIN code, the jQuery handler and the native listener both ran, and the native one kept the page.

Call jQuery submit handlers Native submit listeners Validation
.trigger('submit') run never run skipped
form.submit() never run never run skipped
form.requestSubmit() run run done first

Sending a form with .trigger('submit')

Wrong: $(form).trigger('submit') for a form whose newer code listens with addEventListener('submit'). Right: form.requestSubmit(), or form.requestSubmit(button) to send it as one particular submit button would.

Checkpoint

Check: .trigger('submit') and requestSubmit()

  1. Question 1 of 2 A form's native submit listener sends it with fetch() and calls preventDefault(). A jQuery handler is also attached. What does $(form).trigger('submit') do, the required PIN code field empty?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Runs the jQuery handler, then sends the form the old way with form.submit(), without validation and without the native listener

    The HTML Standard says form.submit() sends the form bypassing interactive constraint validation and without firing a submit event; requestSubmit() does both.

  2. Question 2 of 2 Which call sends a form the way its submit button would, with validation and a submit event?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: form.requestSubmit()

    With requestSubmit() an empty required field stops the form with an invalid event, as the recording showed.

One bubbling CustomEvent reaches both kinds of listener

new CustomEvent(type, { bubbles: true, detail }) makes a real custom event: An event with a name of the page's own, such as cart:updated, made with new CustomEvent(type, { detail, bubbles }) and sent with dispatchEvent(). Its detail carries the data, and listeners of every kind hear it., whose detail holds whatever value the code that sends it gives (DOM Standard). Dispatched on an element, it travels up the page as a click does, so jQuery handlers and native listeners on the way both hear it, and jQuery copies detail onto its own event object (Event Object). Step through the cart plugin, in its old and its new version:

Interactive · step by step One cart event, two kinds of listener Step through the old plugin's .trigger() and the new plugin's CustomEvent, and see which listener each one reaches.
One cart event, two kinds of listener, step 6 of 6. Showing document (badge says 2), section#shop (event.detail.count 2), div#cart, Cart plugin. In focus: document.documentbadge says 2section#shopevent.detail.count 2div#cartwhere the plugin firesCart pluginold or new versionbubbles

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The old version. Add a kettle runs the old plugin: $(cart).trigger('cart:updated', [1]). jQuery makes its own event and passes 1 as an extra parameter.
  2. jQuery handlers only. jQuery walks up to #shop and runs its handlers there: the old handler reads 1 from its second parameter, and event.detail is undefined.
  3. The header misses it. No real event was dispatched, so the native listener on document never ran, and the badge stayed at 0.
  4. The new version. Add a lamp runs the new plugin: cart.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true, detail: { count: 2 } })).
  5. jQuery hears it. The real event bubbles to #shop, where jQuery's handlers run and read event.detail.count, 2; the old handler's extra parameter is now undefined.
  6. The header hears it. It bubbles on to document, where the header's native listener sets the badge to 2. One event reached both kinds of code.

The cart page and cart-events.js

shop/cart-events.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Cart events</title>
  </head>
  <body>
    <header id="header">Cart: <span id="badge">0</span></header>
    <section id="shop">
      <div id="cart" data-count="0">
        <button type="button" id="add-old">Add a kettle (old plugin)</button>
        <button type="button" id="add-new">Add a lamp (new plugin)</button>
      </div>
    </section>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="cart-events.js"></script>
  </body>
</html>

shop/cart-events.js

// cart-events.js: two versions of a cart plugin announcing the new count, and the page's listeners: a jQuery handler
// written for the old version, one written for both, and the header's native listener.
const cart = document.getElementById('cart');
let count = 0;

// Old version: jQuery's own event, with the count as an extra parameter.
$('#add-old').on('click', function () {
  count += 1;
  $(cart).trigger('cart:updated', [count]);
});
// New version: a real event that bubbles, with the count in detail.
$('#add-new').on('click', function () {
  count += 1;
  cart.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true, detail: { count } }));
});

// Written for the old version: it reads the extra parameter.
$('#shop').on('cart:updated', function (event, newCount) {
  console.log(`jQuery handler for the old version: extra parameter ${newCount}, event.detail ${JSON.stringify(event.detail)}`);
});
// Written for both: the count from detail when a real event carries one, else from the extra parameter.
$('#shop').on('cart:updated', function (event, newCount) {
  console.log(`jQuery handler for both: count ${event.detail?.count ?? newCount}`);
});
// The header, without jQuery.
document.addEventListener('cart:updated', function (event) {
  document.getElementById('badge').textContent = String(event.detail.count);
  console.log(`native listener on document: the badge says ${event.detail.count}`);
});
What cart-events.js logged JavaScript · cart-events.mjs
// Prints what shop/cart-events.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/cart-events.html');

Output

▸ click Add a kettle, which uses the old plugin
jQuery handler for the old version: extra parameter 1, event.detail undefined
jQuery handler for both: count 1
▸ click Add a lamp, which uses the new plugin
jQuery handler for the old version: extra parameter undefined, event.detail {"count":2}
jQuery handler for both: count 2
native listener on document: the badge says 2
(jQuery 3.7.1 logged the same 7 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node cart-events.mjs

The old plugin’s .trigger() reached the two jQuery handlers, with the count as an extra parameter, and the header never changed. The new plugin’s event reached all three listeners. The handler written for the old plugin got undefined as its parameter and would have to read event.detail instead; the handler written for both read event.detail?.count ?? newCount and worked with either version, which is the shape to give jQuery handlers while a plugin moves. The badge, written without jQuery, said 2.

Key idea

While jQuery and native code share a page, publish every event with dispatchEvent() of an event that bubbles, with its data in detail, and listen with either API: that one rule reaches everyone, whichever part moves first.

Checkpoint

Check: A CustomEvent for both kinds of code

  1. Question 1 of 2 Which call lets a jQuery handler on #shop and a native listener on document both hear cart:updated fired on #cart?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: cart.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true, detail: { count } }))

    Publish with a bubbling CustomEvent and put the data in detail; listen with either API.

  2. Question 2 of 2 A jQuery handler written as function (event, newCount) worked with .trigger('cart:updated', [count]). The plugin now dispatches a CustomEvent with detail: { count }. What does newCount hold?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: undefined: the count is in event.detail.count now

    During a migration, read event.detail?.count ?? newCount, which works for both versions of the plugin.

Practise: announce a cart change to every listener

Write the one function the cart plugin will call to announce each change; the sample tests run in your browser:

Exercise · Medium · JavaScript

Announce a cart change that every listener hears

A cart plugin announces what it changes with $(el).trigger(type, [detail]), and the header, rewritten without jQuery, never hears any of it. Write publish(el, type, detail) in publish.mjs, which the plugin will call instead of .trigger(), so that jQuery handlers and native listeners both hear every announcement:

  • for change and input, it dispatches a plain Event of that type that bubbles, as the browser's own change and input events do; detail is not used;
  • for any other type, such as cart:updated, it dispatches a CustomEvent that bubbles, can be cancelled, and carries detail;
  • it dispatches exactly one event, and returns what dispatchEvent() returns: false when a listener called preventDefault().

The tests give the module stand-ins for Event, CustomEvent and the element, so they run without a page: each stand-in event keeps the options it was made with, and the stand-in element keeps every event it is sent.

Starter code · publish.mjs

/**
 * Announces a change on el so that jQuery handlers and native listeners both hear it.
 * @param {{ dispatchEvent(event: object): boolean }} el
 * @param {string} type
 * @param {unknown} detail
 * @returns {boolean} what dispatchEvent() returned
 */
export function publish(el, type, detail) {
  return el.dispatchEvent(new CustomEvent(type, { detail }));
}
The sample tests · publish.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { publish } from './publish.mjs';

// Stand-ins for the DOM's Event and CustomEvent: they keep the options they were made with, as the real ones do.
class Event {
  constructor(type, init = {}) {
    this.type = type;
    this.bubbles = init.bubbles === true;
    this.cancelable = init.cancelable === true;
    this.defaultPrevented = false;
  }
  preventDefault() {
    if (this.cancelable) this.defaultPrevented = true;
  }
}
class CustomEvent extends Event {
  constructor(type, init = {}) {
    super(type, init);
    this.detail = init.detail === undefined ? null : init.detail;
  }
}
globalThis.Event = Event;
globalThis.CustomEvent = CustomEvent;

// A stand-in element: it keeps every event it is sent, and a listener on it may cancel them.
function element({ cancels = false } = {}) {
  const sent = [];
  return {
    sent,
    dispatchEvent(event) {
      sent.push(event);
      if (cancels) event.preventDefault();
      return !event.defaultPrevented;
    },
  };
}

test('a custom event is a CustomEvent that bubbles and carries its detail', () => {
  const cart = element();
  publish(cart, 'cart:updated', { count: 3 });
  const [event] = cart.sent;
  assert.ok(event instanceof CustomEvent, 'a CustomEvent');
  assert.equal(event.type, 'cart:updated');
  assert.equal(event.bubbles, true, 'it bubbles');
  assert.deepEqual(event.detail, { count: 3 });
});

test('a custom event can be cancelled, and publish() says so', () => {
  const cart = element({ cancels: true });
  assert.equal(publish(cart, 'cart:emptying', { count: 0 }), false);
  assert.equal(cart.sent[0].cancelable, true);
});

test('publish() returns true when no listener cancels', () => {
  assert.equal(publish(element(), 'cart:updated', { count: 1 }), true);
});

test('change and input are plain events that bubble', () => {
  for (const type of ['change', 'input']) {
    const field = element();
    publish(field, type, 'ignored');
    const [event] = field.sent;
    assert.ok(event instanceof Event && !(event instanceof CustomEvent), `${type} is a plain Event`);
    assert.equal(event.type, type);
    assert.equal(event.bubbles, true, `${type} bubbles`);
  }
});

test('one call sends exactly one event', () => {
  const cart = element();
  publish(cart, 'cart:updated', { count: 2 });
  publish(cart, 'change');
  assert.equal(cart.sent.length, 2);
});
A hint

Events you create do not bubble unless you pass bubbles: true, and preventDefault() does nothing unless the event was made with cancelable: true. Choose the class first: a small set such as new Set(['change', 'input']) tells the two cases apart.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

HTML, CSS & JS Playground (Online Editor) Fire one event with .trigger() and with dispatchEvent() in the playground, with a listener of each kind, and compare the console.

Next, quiz yourself on trigger(), its native neighbours and custom events. The set leans on whatever you get wrong, and a reason appears under every option you choose:

Practice that adapts to you

Practise this lesson

12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 After running the jQuery handlers, what does .trigger(type) do if nobody prevented the default?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Calls the element's own method of that name, such as click() or focus(), if it has one

    jQuery's documentation: it attempts to invoke the property of the same name as a method when no handler called preventDefault().

  2. Question 2 of 12 A form uses one native click listener on its fieldset to save every checkbox change. Code elsewhere ticks a checkbox with $(box).trigger('click'). Does the fieldset's listener save it?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: No: the box is ticked, but jQuery stops the real click at the checkbox, so the fieldset never hears it

    Tick it with box.click() instead, or listen for change, which the checkbox fires on its own when it is ticked.

  3. Question 3 of 12 Which of these moved the focus into the PIN code field?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: $(pin).trigger('focus') and pin.focus()

    Events describe what happened; methods make it happen. Only focus() moves the focus.

  4. Question 4 of 12 A component written without jQuery listens for input and change on a field that an old jQuery plugin fills with .val(x).trigger('change'). The component never updates. What is the smallest fix in the plugin?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: After setting the value, dispatch new Event('input', { bubbles: true }) and new Event('change', { bubbles: true })

    The plugin should announce a change the way a reader's typing does: input, then change, both bubbling.

  5. Question 5 of 12 Old markup has <select onchange="saveSlot()">. Does $(select).trigger('change') run saveSlot()?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Yes: .trigger() calls each element's onchange property on the way

    This is why old pages with inline handlers seemed to work with .trigger(), and why code moved to addEventListener() suddenly stops hearing it.

  6. Question 6 of 12 Which are true of .triggerHandler(), compared with .trigger()?

    Choose every answer that is right.

    Show the answer: Question 6 of 12

    Answer:

    • It does not bubble
    • It returns the last handler's value instead of the jQuery object
    • It runs on the first element of the collection only

    It also skips the element's own method, so .triggerHandler('submit') never sends a form.

  7. Question 7 of 12 A handler is written function (event, count, source). Which call passes 3 and 'checkout' to it?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: $el.trigger('cart:changed', [3, 'checkout'])

    Extra parameters exist only between .trigger() and jQuery handlers; native code gets data through detail.

  8. Question 8 of 12 What does form.submit() skip that clicking the form's submit button does?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Constraint validation and the submit event

    That is why .trigger('submit'), which ends by calling form.submit(), skips both.

  9. Question 9 of 12 An old "Pay" link runs $(form).trigger('submit'). The form is moving to a native submit listener that validates the coupon and sends with fetch(). What should the link run?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: form.requestSubmit(), so validation runs and both the jQuery handler and the native listener hear submit

    requestSubmit() is the one call that does what a reader's click on the submit button does.

  10. Question 10 of 12 A native CustomEvent with detail { count: 2 } reaches a jQuery handler. Where does the handler find the count?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: event.detail.count, or event.originalEvent.detail.count

    jQuery's event object copies a list of properties from the browser's event, detail among them.

  11. Question 11 of 12 A plugin dispatches new CustomEvent('cart:updated', { detail }) on #cart, and a listener on document never runs. Why?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: The event does not bubble: bubbles defaults to false for events you create

    The browser's own click and change bubble; events you make bubble only when you say so.

  12. Question 12 of 12 A page mixes jQuery handlers and native code during a migration. Which rule keeps every listener working while modules move?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Publish every event with dispatchEvent() and bubbles: true, data in detail; listen with either API

    One publishing rule, real events that bubble, lets the modules move in any order.

Interview questions

Warm-up (fresher to 2–5 years): how do .trigger() and .triggerHandler() differ?

.trigger() runs the jQuery handlers for the event on every element of the collection, bubbling through their ancestors, then calls the element’s own method of that name if one exists, such as click() or submit(), and returns the collection for chaining. .triggerHandler() runs the jQuery handlers of the first element only, does not bubble, never calls the element’s method, and returns what the last handler returned. Neither reaches listeners added with addEventListener() for change or a custom event: only a real event from dispatchEvent() does. Use .triggerHandler() to ask one element’s handlers for an answer without side effects.

Two longer questions of this module grow out of this lesson: a checkout that ignores a jQuery day picker, and a form that skips its new submit code. Work out your own answer to each before you read its first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A checkout rewritten without jQuery never updates its delivery charge when a jQuery date picker sets the date field; the picker calls .val(date).trigger('change'). Explain why, fix the picker without breaking the page's old jQuery handlers, and say how you would find other plugins with the same problem.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A payment form's new code validates the coupon and sends the order with fetch() from a native submit listener. Orders placed from an old Pay now link, which runs $(form).trigger('submit'), reload the page and skip both the coupon check and the required fields. Explain what happens and fix it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • .trigger() runs jQuery’s stored handlers up the path and the on… properties, then the element’s own method of that name if it has one; only that method makes an event native listeners hear.
  • change, input and custom names have no such method, so a triggered one never reaches addEventListener() code, and .val() fires nothing at all: dispatch a real input and change that bubble.
  • Extra parameters and .triggerHandler() exist only inside jQuery: the first gives jQuery handlers more arguments, the second runs one element’s handlers, without bubbling, and returns their value.
  • .trigger('submit') ends in form.submit(), which skips validation and the submit event; form.requestSubmit() does both, as the form’s own button would.
  • A CustomEvent with bubbles: true and its data in detail reaches jQuery handlers and native listeners alike, and jQuery handlers read it as event.detail.

Terms in this lesson

constraint validation
The browser's check of a form's fields against their rules, such as required, pattern or type="email", before the form is sent. A failed check fires an invalid event at the field and stops the sending.
custom event
An event with a name of the page's own, such as cart:updated, made with new CustomEvent(type, { detail, bubbles }) and sent with dispatchEvent(). Its detail carries the data, and listeners of every kind hear it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.