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.
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
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);
}
})(); // 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
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:
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}`); // 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The old plugin writes. The reader clicks Tuesday. The old plugin writes tue into the field with .val(), which fires no event at all.
- 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.
- 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.
- The fixed plugin. The fixed plugin sets field.value and dispatches a real input event that bubbles, as typing would.
- 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.
- Then change. A real change event follows. The old jQuery change handler hears it too, so code of both kinds keeps working.
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}`);
}); // 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
// 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}`);
}); // 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
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:
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}`); // 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()
.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:
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();
}); // 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()
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- The header misses it. No real event was dispatched, so the native listener on document never ran, and the badge stayed at 0.
- The new version. Add a lamp runs the new plugin: cart.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true, detail: { count: 2 } })).
- 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.
- 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.
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}`);
}); // 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
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
changeandinput, it dispatches a plainEventof that type that bubbles, as the browser's own change and input events do;detailis not used; - for any other type, such as
cart:updated, it dispatches aCustomEventthat bubbles, can be cancelled, and carriesdetail; - it dispatches exactly one event, and returns what
dispatchEvent()returns:falsewhen a listener calledpreventDefault().
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
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.
-
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.
-
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.
Key takeaways
.trigger()runs jQuery’s stored handlers up the path and theon…properties, then the element’s own method of that name if it has one; only that method makes an event native listeners hear.change,inputand custom names have no such method, so a triggered one never reachesaddEventListener()code, and.val()fires nothing at all: dispatch a realinputandchangethat 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 inform.submit(), which skips validation and the submit event;form.requestSubmit()does both, as the form’s own button would.- A
CustomEventwithbubbles: trueand its data indetailreaches jQuery handlers and native listeners alike, and jQuery handlers read it asevent.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
- .trigger() (OpenJS Foundation (jQuery API documentation))
- .triggerHandler() (OpenJS Foundation (jQuery API documentation))
- Triggering Event Handlers (OpenJS Foundation (jQuery Learning Center))
- Event Object (OpenJS Foundation (jQuery API documentation))
- DOM Standard: interface CustomEvent (WHATWG)
- DOM Standard: dispatching events (WHATWG)
- HTML Standard: the form element, submit() and requestSubmit() (WHATWG)
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress