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 7 – Migrating to modern DOM APIs

Running jQuery and native event code side by side

Let jQuery handlers and addEventListener() code share a page mid-migration: one event bridge, removal without doubles, handler order, and React or Vue fields.

  • Advanced
  • 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 an event reaches when jQuery handlers and native listeners share a page
  • Connect a jQuery plugin and a native module through one emit() helper and bubbling CustomEvents
  • Migrate a binding together with its removal, so that no handler is lost or runs twice
  • Choose one owner for each element where native listeners and delegated jQuery handlers meet
  • Update React and Vue fields from a jQuery plugin with real events and the prototype's value setter

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict which handlers an event reaches when jQuery handlers and native listeners share a page
  • Connect a jQuery plugin and a native module through one emit() helper and bubbling CustomEvents
  • Migrate a binding together with its removal, so that no handler is lost or runs twice
  • Choose one owner for each element where native listeners and delegated jQuery handlers meet
  • Update React and Vue fields from a jQuery plugin with real events and the prototype's value setter

Before you start

On this page

A filter panel half moved off jQuery ran its search three times for every key press in this lesson’s recording: once for the old jQuery handler, once for the new native listener, and once more for a native listener that a reload had left behind. Every result was right, so nothing on the page looked wrong; the server simply answered each query three times.

While old and new code share a page, events cross between them by four rules: which call fires an event, which one removes a handler, which handler runs first, and what a framework counts as a change. Each rule below ran on a page of a small shop in jsdom, with jQuery 4.0.0 and 3.7.1, and with React 19 and Vue 3.

Real events cross both ways; triggered ones stay in jQuery

A real event is one that dispatchEvent() sends, or the browser itself. It travels the page, so a jQuery handler and a native listener on its path both hear it. .trigger() with a custom name sends no real event at all: it runs the handlers jQuery stored, and then the element’s own method of that name if there is one (.trigger()), as this track’s lesson on triggering shows in detail. A page that mixes the two needs one way to announce a change that both kinds of code hear. The shop uses a three-line helper, emit(), which dispatches a 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. More in “trigger(), custom events and native listeners” that bubbles, with its data in detail (DOM Standard). Follow three clicks on its cart page:

Interactive · message by message One bridge between a jQuery plugin and a native badge Step through three clicks on the cart page, two that go through emit() and one that still uses .trigger().
One bridge between a jQuery plugin and a native badge, step 6 of 6. Messages so far: Cart plugin to The page: cart:changed, bubbles; The page to Cart badge: detail.count is 1; The page to Cart plugin: event.detail.count is 1; Cart badge to The page: cart:cleared, bubbles.Cart pluginTypeError: detail undefinedThe pagethe event pathCart badgeshows 0cart:changed, bubblesdetail.count is 1event.detail.count is 1cart:cleared, bubbles

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

Every step as text
  1. Add, through emit(). A click on Add runs the plugin's delegated handler, which calls emit() on the button. emit() dispatches a CustomEvent named cart:changed that bubbles, with the count in detail.
  2. The badge hears it. The event reaches the document, where the badge's native listener reads event.detail.count and shows 1.
  3. So does jQuery. The plugin's own handler on the document hears the same real event. jQuery copies detail onto its event object, so event.detail.count is 1 there too.
  4. Clear, the other way. The badge's Clear button calls emit() for cart:cleared. A real event, so the jQuery handler on
  5. The promotion triggers. The promotion script still calls .trigger('cart:changed', [{ count: 5 }]). jQuery runs its own handlers and dispatches nothing, so the page and the badge never hear it.
  6. And a handler breaks. The plugin's handler reads event.detail.count, and a triggered event has no detail, so it throws a TypeError. Moving the promotion to emit() fixed both, with a count of 5 everywhere.

The jQuery cart plugin and the native badge both call emit(), and an old promotion script still calls .trigger():

The cart page and its scripts

shop/bridge.js

// bridge.js: the one way this shop announces a change, for old jQuery code and new native code alike. A real event
// that bubbles reaches jQuery handlers and native listeners on its way up; its data travels in detail.
function emit(el, type, detail) {
  return el.dispatchEvent(new CustomEvent(type, { bubbles: true, cancelable: true, detail: detail }));
}

shop/cart-plugin.js

// cart-plugin.js: the old cart, still a jQuery plugin. It announces every change through emit() from bridge.js.
jQuery(function ($) {
  var count = 0;
  $('#products').on('click', '.add', function () {
    count += 1;
    emit(this, 'cart:changed', { count: count, sku: $(this).data('sku') });
  });

  // An old handler of the plugin's own: it reads the data from the event, as native listeners do.
  $(document).on('cart:changed', function (event) {
    console.log('jQuery handler on document: event.detail.count is ' + event.detail.count + ', event.originalEvent.detail.count is ' + event.originalEvent.detail.count);
  });
  $('#cart').on('cart:cleared', function (event) {
    count = event.detail.count;
    console.log('jQuery handler on #cart heard cart:cleared, count ' + count);
  });
});

shop/badge.js

// badge.js: the new cart badge, written without jQuery. It listens for the plugin's events and sends its own.
const badge = document.querySelector('#badge');
document.addEventListener('cart:changed', (event) => {
  badge.textContent = String(event.detail.count);
  console.log(`native listener on document: badge shows ${badge.textContent}`);
});
document.querySelector('#clear').addEventListener('click', (event) => {
  badge.textContent = '0';
  emit(event.currentTarget, 'cart:cleared', { count: 0 });
});

shop/promo.js

// promo.js: a promotion script nobody has moved yet. It still announces with .trigger().
jQuery(function ($) {
  $('#promo').on('click', function () {
    $('#cart').trigger('cart:changed', [{ count: 5 }]);
  });
});

shop/bridge.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Cart: one bridge for both kinds of code</title>
</head>
<body>
  <ul id="products">
    <li>Kettle <button type="button" class="add" data-sku="K-7310">Add</button></li>
    <li>Lamp <button type="button" class="add" data-sku="L-2024">Add</button></li>
  </ul>
  <div id="cart">Cart: <span id="badge">0</span> items
    <button type="button" id="clear">Clear</button>
    <button type="button" id="promo">Apply the promotion</button>
  </div>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
  <script src="bridge.js"></script>
  <script src="cart-plugin.js"></script>
  <script src="badge.js"></script>
  <script src="promo.js"></script>
</body>
</html>
What the cart page logged JavaScript · bridge.mjs
// Prints what shop/bridge.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/bridge.html');

Output

▸ click Add next to the kettle (the jQuery plugin, through emit)
native listener on document: badge shows 1
jQuery handler on document: event.detail.count is 1, event.originalEvent.detail.count is 1
▸ click Clear (the native badge, through emit)
jQuery handler on #cart heard cart:cleared, count 0
▸ click Apply the promotion (the old script, with .trigger)
(error) Uncaught TypeError: Cannot read properties of undefined (reading 'count')
(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 bridge.mjs

The two emit() calls crossed both ways: the plugin’s event reached the badge’s native listener, and the badge’s event reached the plugin’s handler on #cart. In the plugin’s handler, event.detail.count and event.originalEvent.detail.count were both 1, because jQuery copies detail onto its own event object, so a jQuery handler reads real events the way a native listener does. The promotion’s .trigger() failed twice: the badge never heard it, and the plugin’s handler threw, because a triggered event has no detail; its data arrives as an extra argument instead. Moved to emit(), the same click reached both:

The promotion, moved to emit() JavaScript · bridge-fixed.mjs
// Prints what shop/bridge-fixed.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

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

Output

▸ click Apply the promotion (the moved script, through emit)
native listener on document: badge shows 5
jQuery handler on document: event.detail.count is 5, event.originalEvent.detail.count is 5
(jQuery 3.7.1 logged the same 3 lines)

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

Moving a listener before the code that announces to it

Wrong: rewriting the badge with addEventListener('cart:changed', …) while scripts still announce with .trigger('cart:changed'): the badge stops updating with no error at all. Right: move every announcement to emit() first, which jQuery handlers hear as well, and only then move listeners, one at a time.

Checkpoint

Check: Real events and triggered ones

  1. Question 1 of 2 The old promotion script calls $('#cart').trigger('cart:changed', [{ count: 5 }]). The badge listens on document with addEventListener('cart:changed', …). What does the badge do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing: it never hears the event

    .trigger() with a custom name stays inside jQuery. A page that mixes the two kinds of code announces with a real event, such as emit()'s bubbling CustomEvent.

  2. Question 2 of 2 A jQuery handler on document reads event.detail.count. Which announcement on a button lets it read the count?

    Choose one answer.

    Show the answer: Question 2 of 2

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

    This is why the shop's emit() sets bubbles and puts the data in detail: both kinds of code then read the same event the same way.

Each API removes only what it added

.off() “removes event handlers that were attached with .on()” (.off()), and that is all it removes. The reason is how jQuery attaches: the first .on() for an event type adds one listener of jQuery’s own to the element, and every handler you pass goes into a list that jQuery keeps (jQuery’s event.js). removeEventListener() needs the very function that was added, and your function was never added, so it removes nothing; and .off() never sees the native listeners. The shop’s filter panel shows what that does to a half-finished migration, where both the old jQuery setup and the new native one still run:

The filter panel, half migrated

shop/search.js

// search.js: what the filter panel runs for every change of its field. Here it only counts and logs each run.
let searches = 0;
function search(event) {
  searches += 1;
  console.log(`search #${searches} for "${event.target.value}"`);
}

shop/filter-old.js

// filter-old.js: the panel's jQuery code. initFilter() runs on load and again whenever the panel is reloaded.
function initFilter() {
  jQuery('#filter').off('.filter').on('input.filter', search);
}
jQuery(function ($) {
  initFilter();
  $('#reload').on('click', initFilter);
});

shop/filter-new.js

// filter-new.js: the first step of a migration. It adds a native listener on every setup, and the jQuery code of
// filter-old.js is still on the page.
const field = document.querySelector('#filter');
function initFilterNative() {
  field.addEventListener('input', (event) => search(event));
}
initFilterNative();
document.querySelector('#reload').addEventListener('click', initFilterNative);

shop/remove-native.js

// remove-native.js: an attempt to take the panel down with the native API.
document.querySelector('#filter').removeEventListener('input', search);

shop/remove-jquery.js

// remove-jquery.js: an attempt to take the panel down with jQuery.
jQuery('#filter').off('input');

shop/filter.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Filter panel, half migrated</title>
</head>
<body>
  <label for="filter">Find a product</label> <input id="filter" type="search">
  <button type="button" id="reload">Reload the panel</button>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
  <script src="search.js"></script>
  <script src="filter-old.js"></script>
  <script src="filter-new.js"></script>
</body>
</html>
What the half-migrated panel logged JavaScript · filter.mjs
// Prints what shop/filter.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/filter.html');

Output

▸ type p
search #1 for "p"
search #2 for "p"
▸ click Reload the panel
▸ type u
search #3 for "pu"
search #4 for "pu"
search #5 for "pu"
▸ run remove-native.js: removeEventListener('input', search)
▸ type n
search #6 for "pun"
search #7 for "pun"
search #8 for "pun"
▸ run remove-jquery.js: $('#filter').off('input')
▸ type e
search #9 for "pune"
search #10 for "pune"
(jQuery 3.7.1 logged the same 17 lines)

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

Count the searches per key press: 2 at first, one per owner; 3 after a reload, because the old setup removed and re-added its jQuery handler while the new setup added a second arrow function, and addEventListener() ignores only a function that is already registered. removeEventListener('input', search) removed nothing: search is in jQuery’s list, and the native listeners are arrow functions. .off('input') removed the jQuery handler only, and two native listeners stayed. The fix moves the binding and its removal in one change, as the flow shows:

Interactive · stage by stage Moving one binding from jQuery to native code Follow the filter panel from its half-migrated state to one owner, and see what each step changes in the count of searches a key press runs.
Moving one binding from jQuery to native code, step 7 of 7. Showing List every binding (3 runs per key press), Choose one owner, Move binding and removal, Give it a signal, Count runs per event (1, then 1, then 0). In focus: Count runs per event.skipped in the half moveList every binding3 runs per key pressChoose one ownernative code from now onMove binding and removalin the same changeGive it a signalone AbortController per setupCount runs per event1, then 1, then 0

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

Every step as text
  1. The half move. The panel's first migration step added a native listener and left the jQuery handler in place. Every key press ran the search twice, once for each.
  2. A reload adds one more. Each reload ran the old setup, which removed and re-added the jQuery handler, and the new one, which added another arrow function. After one reload a key press ran the search three times.
  3. List the bindings. Write down every handler on the field, who adds it and who removes it. jQuery keeps its own list; native listeners are only in the code that adds them.
  4. One owner. The native code becomes the only owner of the field's input event. The jQuery handler goes, and so does the setup that re-added it.
  5. Binding and removal together. The change that adds the native listener also deletes the jQuery binding and its .off() call, so no state exists where both run or neither does.
  6. A signal per setup. Each setup aborts the last AbortController and makes a new one, and passes its signal to addEventListener. A reload can no longer leave a second listener behind.
  7. The count holds. The fixed panel ran one search per key press, one after a reload and none after its teardown. A test that counts runs keeps it that way.

The fixed panel has one owner, and one AbortController: A DOM object with a signal and an abort() method. A listener added with addEventListener(type, fn, { signal }) is removed when abort() is called, so one controller can remove all the listeners of a feature at once. More in “jQuery events: on(), off(), one() and delegation” per setup, whose signal removes everything that setup added (DOM Standard):

The panel's events, moved in one change

shop/filter-fixed.js

// filter-fixed.js: the panel's events moved in one change. The native listener replaces the jQuery handler, and one
// AbortController per setup takes down everything that setup added.
const field = document.querySelector('#filter');
let setup = null;
function initFilter() {
  setup?.abort();
  setup = new AbortController();
  field.addEventListener('input', (event) => search(event), { signal: setup.signal });
}
function teardownFilter() {
  setup?.abort();
  setup = null;
}
initFilter();
document.querySelector('#reload').addEventListener('click', initFilter);

shop/teardown.js

// teardown.js: the fixed panel's teardown, as the page calls it when the panel closes.
teardownFilter();

shop/filter-fixed.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Filter panel, events moved in one change</title>
</head>
<body>
  <label for="filter">Find a product</label> <input id="filter" type="search">
  <button type="button" id="reload">Reload the panel</button>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
  <script src="search.js"></script>
  <script src="filter-fixed.js"></script>
</body>
</html>
What the fixed panel logged JavaScript · filter-fixed.mjs
// Prints what shop/filter-fixed.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

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

Output

▸ type p
search #1 for "p"
▸ click Reload the panel
▸ type u
search #2 for "pu"
▸ run teardown.js: teardownFilter()
▸ type n

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

One search per key press, one after the reload, and none after teardownFilter(): each setup aborts the last signal before it adds its listener, so a reload cannot leave one behind.

Key idea

Move a binding and its removal in the same change, and give native listeners a signal: then every setup adds exactly one listener, and every teardown removes all of them.

Checkpoint

Check: Removing a handler with the API that added it

  1. Question 1 of 2 filter-old.js added search with $('#filter').on('input.filter', search). Another script then calls field.removeEventListener('input', search). What happens to the jQuery handler?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It keeps running: search was never added with addEventListener()

    Remove a jQuery handler with .off(), a native listener with removeEventListener() or its signal, and move both calls with their bindings.

  2. Question 2 of 2 After one reload, the half-migrated panel ran three searches per key press. Where did the third one come from?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: filter-new.js added a new arrow function at every setup, and nothing ever removed the first one

    The fixed setup aborts the last signal before it adds its listener, so a reload replaces the listener instead of adding another.

The button’s own listener runs before the list’s delegated handler

A delegated jQuery handler is not on the element it handles. It runs when the event reaches the container where jQuery’s listener sits, so a native listener on the element itself always runs first. An event’s path has three parts: down from the window through the document in the capture phase: The first part of an event's path: from the window down through the target's ancestors, before the target itself. Only listeners added with { capture: true } run then; jQuery never adds any, so its handlers all run later, while the event bubbles., at the target, then back up as it bubbles (DOM Standard). jQuery 4 listens only while the event bubbles, and at the container it runs its delegated handlers first and the container’s own handlers after them. jQuery 3 has one exception: it runs focusin and focusout handlers from a capture listener for focus and blur on the document, a shim that jQuery 4 dropped (upgrade guide). Follow one click on the cart list, where old jQuery code delegates from the list and new native code listens on each button:

Interactive · step by step One click on Remove, and who hears it in which order Step through the click on the kettle's Remove button, from the capture phase down to the button and back up to the document.
One click on Remove, and who hears it in which order, step 6 of 6. Showing document (1: capture, 5: bubble), ul#cart, button#kettle (2: native listener), jQuery's dispatcher (3: delegated, 4: direct). In focus: document.downdownupupdocument1: capture, 5: bubbleul#cartjQuery's one listenerbutton#kettle2: native listenerjQuery's dispatcher3: delegated, 4: directbubbles

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

Every step as text
  1. Capture at the top. The click first travels down from the window through the document. The native listener added there with capture true runs before anything else, as the recording logged.
  2. Down to the button. The event passes the list on its way down. jQuery adds no capture listener for a click, so nothing runs there yet.
  3. The target. At the button, the new native listener runs second. Had it called stopPropagation(), as the lamp's listener does, nothing below this step would run.
  4. Up to the list. The event bubbles to the list, where jQuery's one native listener hands it to jQuery's dispatcher.
  5. Delegated, then direct. jQuery runs the delegated handler for .remove third and the list's own handler fourth. A delegated handler that returns false, as the mat's does, stops both the rest of jQuery's list and the bubbling.
  6. The document last. The event bubbles on to the document, whose bubbling native listener runs fifth and last.

The page has three Remove buttons. The lamp’s native listener calls stopPropagation(), and the jQuery handler of the mat’s button returns false:

The cart list and its two scripts

shop/order-old.js

// order-old.js: the cart list's jQuery code: one delegated handler for every Remove button, one for the list itself.
jQuery(function ($) {
  $('#cart').on('click', '.remove', function () {
    console.log(`jQuery, delegated on #cart for ${this.id}`);
    if ($(this).data('stop') === 'jquery') return false;
  });
  $('#cart').on('click', function () {
    console.log('jQuery, direct on #cart');
  });
});

shop/order-new.js

// order-new.js: new native code on the same list: a listener on each button, and two on the document.
document.addEventListener('click', () => console.log('native, on document, capture phase'), { capture: true });
for (const button of document.querySelectorAll('#cart .remove')) {
  button.addEventListener('click', (event) => {
    console.log(`native, on ${button.id}`);
    if (button.dataset.stop === 'native') event.stopPropagation();
  });
}
document.addEventListener('click', () => console.log('native, on document'));

shop/order.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Cart list: who runs first</title>
</head>
<body>
  <ul id="cart">
    <li>Kettle <button type="button" class="remove" id="kettle">Remove</button></li>
    <li>Lamp <button type="button" class="remove" id="lamp" data-stop="native">Remove</button></li>
    <li>Mat <button type="button" class="remove" id="mat" data-stop="jquery">Remove</button></li>
  </ul>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
  <script src="order-old.js"></script>
  <script src="order-new.js"></script>
</body>
</html>
Who ran, in which order, for each click JavaScript · order.mjs
// Prints what shop/order.html logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('shop/order.html');

Output

▸ click Remove next to the kettle
native, on document, capture phase
native, on kettle
jQuery, delegated on #cart for kettle
jQuery, direct on #cart
native, on document
▸ click Remove next to the lamp (its native listener calls stopPropagation)
native, on document, capture phase
native, on lamp
▸ click Remove next to the mat (its jQuery handler returns false)
native, on document, capture phase
native, on mat
jQuery, delegated on #cart for mat
(jQuery 3.7.1 logged the same 13 lines)

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

The kettle’s click ran all five, in the order of the path. The lamp’s ran two: its native listener stopped the bubbling, so jQuery’s delegated handler never ran, and nothing told the old code that a click had been taken from it. The mat’s ran three: return false in jQuery means preventDefault() and stopPropagation(), which stopped the rest of jQuery’s list and the native listener on the document, while the button’s native listener had already run.

The new code does this What the old jQuery code sees
listens on the button, without stopping nothing changes: both run, the native listener first
calls stopPropagation() on the button its delegated handler never runs
listens on the document runs after the old handlers, and never when they return false

So give each element one owner for each event. While a list’s buttons move, either the delegated handler acts for a button or the native listener does, never both, and neither stops propagation to protect itself.

Stopping propagation to be safe

Wrong: event.stopPropagation() in a new listener, so that “the old code cannot interfere”. It silently turns off every delegated handler above the element, analytics included. Right: remove the old handler for that element in the same change, and let the event bubble.

Checkpoint

Check: Who runs first, and one owner per element

  1. Question 1 of 2 On the kettle's click, which of these ran first: the button's native listener, or the delegated jQuery handler on #cart?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The button's native listener

    Capture on document, then the button, then jQuery at the list (delegated first, then direct), then the document again, as the recording logged.

  2. Question 2 of 2 New code on the lamp's button calls event.stopPropagation() in its click listener. What does that do to the old delegated jQuery handler on #cart?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It never runs, and nothing reports it

    Give each element one owner per event instead: remove the old handler for that element in the same change, and let the event bubble.

Framework fields listen natively, and React checks the value too

React and Vue listen with native listeners, so a jQuery plugin that writes into a field they render must send real events. React’s onChange on an input “Behaves like the browser input event” (React), and Vue’s v-model on a text field uses the value property and the input event (Vue.js). React adds one more check, its value tracking: on every A form field whose value a framework keeps in its own state and writes back into the field on every render, so that the framework, not the browser, decides what the field holds. it defines a value property of its own that remembers each value written, and it calls onChange only when the field’s value differs from the remembered one (inputValueTracking.js). The shop’s slot picker writes the chosen slot in one of three ways, into a React form and a Vue form:

Three ways to write the slot, and the two forms

shop/set-slot.js

// set-slot.js: three versions of the line in the shop's jQuery slot picker that writes the chosen slot into a field.
function setSlotWithTrigger(input, slot) {
  // The plugin as it was: the value, then a triggered change.
  jQuery(input).val(slot).trigger('change');
}
function setSlotWithEvent(input, slot) {
  // The first fix: the value, then a real input event that bubbles.
  input.value = slot;
  input.dispatchEvent(new Event('input', { bubbles: true }));
}
function setSlotWithSetter(input, slot) {
  // The second fix: the value through the setter of the element's own prototype, then real input and change events.
  const { set } = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value');
  set.call(input, slot);
  input.dispatchEvent(new Event('input', { bubbles: true }));
  input.dispatchEvent(new Event('change', { bubbles: true }));
}

frameworks/react-slot.mjs

// react-slot.mjs: the delivery form written with React 19. Its field is controlled: React keeps the value in state,
// and onChange is how the state learns of a new value.
import { createElement as h, useState } from 'react';

export function SlotForm() {
  const [slot, setSlot] = useState('');
  return h(
    'form',
    null,
    h('label', { htmlFor: 'slot-react' }, 'Delivery slot'),
    h('input', { id: 'slot-react', value: slot, onChange: (event) => setSlot(event.target.value) }),
    h('p', { id: 'summary-react' }, slot ? `Delivery: ${slot}` : 'Delivery: choose a slot'),
  );
}

frameworks/vue-slot.mjs

// vue-slot.mjs: the same form written with Vue 3. v-model keeps the field and the data in step; written without a
// template, it is the vModelText directive on the input.
import { createApp, h, ref, vModelText, withDirectives } from 'vue';

export function mountSlotForm(el) {
  const slot = ref('');
  const field = () => withDirectives(h('input', { id: 'slot-vue', 'onUpdate:modelValue': (value) => (slot.value = value) }), [[vModelText, slot.value]]);
  const summary = () => h('p', { id: 'summary-vue' }, slot.value ? `Delivery: ${slot.value}` : 'Delivery: choose a slot');
  createApp({ render: () => h('form', [h('label', { for: 'slot-vue' }, 'Delivery slot'), field(), summary()]) }).mount(el);
}
What each form showed after each way of writing JavaScript · forms.mjs
// Prints what the React and the Vue delivery forms showed after each version of shop/set-slot.js wrote their field,
// in jsdom (recorded.mjs).
import { showForms } from './show.mjs';

showForms();

Output

React 19.3.1:
  ▸ the picker writes Tue morning with .val().trigger('change')
  the field shows "Tue morning"; the form says "Delivery: choose a slot"
  ▸ the picker writes Wed evening with input.value and an input event
  the field shows "Wed evening"; the form says "Delivery: choose a slot"
  ▸ the picker writes Thu morning with the prototype's setter, then input and change events
  the field shows "Thu morning"; the form says "Delivery: Thu morning"
Vue 3.5.44:
  ▸ the picker writes Tue morning with .val().trigger('change')
  the field shows "Tue morning"; the form says "Delivery: choose a slot"
  ▸ the picker writes Wed evening with input.value and an input event
  the field shows "Wed evening"; the form says "Delivery: Wed evening"
  ▸ the picker writes Thu morning with the prototype's setter, then input and change events
  the field shows "Thu morning"; the form says "Delivery: Thu morning"

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

.trigger('change') reached neither form: the field showed Tue morning while both still said “choose a slot”. An input event after input.value = … was enough for Vue, which read the field’s value, and not for React. Step through why:

Interactive · message by message Why React ignored the first fix and heard the second Step through the picker writing a slot into React's field, first with input.value and an input event, then through the prototype's setter.
Why React ignored the first fix and heard the second, step 6 of 6. Messages so far: Slot picker to The input: input.value = Wed evening; The input to React: input, bubbles; Slot picker to The input: prototype setter; The input to React: input, bubbles.Slot pickerjQuery pluginThe inputremembers: Wed eveningReactstate: Thu morninginput.value = Wed eveninginput, bubblesprototype setterinput, bubbles

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

Every step as text
  1. React wraps the value. When React renders a controlled input, it defines its own value property on that input. The property passes every write on to the browser and remembers the value written.
  2. The first fix writes. The picker sets input.value to Wed evening. That goes through React's property, so React now remembers Wed evening.
  3. An input event. The picker dispatches an input event that bubbles. React's listener at the root compares the field's value with the one it remembers.
  4. No change seen. Both say Wed evening, so React decides nothing changed and never calls onChange. The field shows the slot; the form still says choose a slot.
  5. The second fix writes. The picker calls the setter of the input's prototype instead. The browser's value changes to Thu morning, and React's remembered value stays Wed evening.
  6. A change React sees. The input event now finds two different values, so React calls onChange with Thu morning, and the form says Delivery Thu morning. Vue's v-model heard both fixes, because it reads the value on every input event.

input.value = … went through React’s own property, so the remembered value and the field’s value matched and React saw no change. The prototype’s setter wrote past that property, and the next input event showed React a difference. The third version also dispatches change, for native code and plugins that listen for it.

An internal detail, recorded on React 19.3.1

The value tracking is how React works, not an API it promises: the source file is the evidence, and the recording shows React 19.3.1 in jsdom. Writing through the prototype’s setter and dispatching input is the way a real keystroke changes a field, so it keeps working however React tracks values.

Checkpoint

Check: Writing into React and Vue fields

  1. Question 1 of 2 A jQuery plugin sets input.value = 'Wed evening' on a React controlled input and dispatches new Event('input', { bubbles: true }). What did the form show?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The field showed Wed evening, and the form still said "choose a slot"

    Write through Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value').set, then dispatch input and change.

  2. Question 2 of 2 The same plugin writes Wed evening with input.value and a bubbling input event into a Vue field bound with v-model. What did the Vue form show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: "Delivery: Wed evening"

    Vue's docs: text inputs use the value property and the input event. The setter version works for Vue too, so one function serves both.

Practise: write the field setter frameworks hear

Write setFieldValue(field, value), the one function the slot picker will call. The sample tests run in your browser, with a stand-in field that is wrapped the way React wraps a field it controls:

Exercise · Medium · JavaScript

Write a field's value so that React and Vue both hear it

The shop's slot picker is a jQuery plugin that writes the chosen slot into a field, and the forms around it are now written with React and Vue. Write setFieldValue(field, value) in set-field.mjs, the one function the plugin will call, so that every framework hears the new value:

  • it writes value through the value setter of the field's prototype chain: the first prototype, going up from Object.getPrototypeOf(field), that has a value property with a setter. A property on the field itself, such as the one React adds, is skipped;
  • it throws a TypeError when no prototype has such a setter;
  • then it dispatches an input event and a change event, in that order, both made with bubbles: true.

The tests give the module a stand-in for Event and stand-in fields, so they run without a page: a stand-in field keeps every event it is sent, and one of them is wrapped the way React wraps a field it controls.

Starter code · set-field.mjs

/**
 * Writes a value into a form field so that native listeners, React and Vue all hear it.
 * @param {{ value: string, dispatchEvent(event: object): boolean }} field
 * @param {string} value
 */
export function setFieldValue(field, value) {
  field.value = value;
  field.dispatchEvent(new Event('input', { bubbles: true }));
}
The sample tests · set-field.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { setFieldValue } from './set-field.mjs';

// A stand-in for the DOM's Event: it keeps the options it was made with, as the real one does.
class Event {
  constructor(type, init = {}) {
    this.type = type;
    this.bubbles = init.bubbles === true;
  }
}
globalThis.Event = Event;

// A stand-in field: the value accessor lives on its prototype, as it does on HTMLInputElement.prototype.
class Field {
  constructor() {
    this.browserValue = '';
    this.sent = [];
  }
  get value() {
    return this.browserValue;
  }
  set value(v) {
    this.browserValue = String(v);
  }
  dispatchEvent(event) {
    this.sent.push(event);
    return true;
  }
}
class SearchField extends Field {}

// What React does to a field it controls: a value property of the field's own that remembers every write.
function controlledByReact(field) {
  const { get, set } = Object.getOwnPropertyDescriptor(Field.prototype, 'value');
  field.remembered = get.call(field);
  Object.defineProperty(field, 'value', {
    configurable: true,
    get() {
      return get.call(this);
    },
    set(v) {
      field.remembered = String(v);
      set.call(this, v);
    },
  });
  return field;
}

test('the value reaches the field', () => {
  const field = new Field();
  setFieldValue(field, 'Thu morning');
  assert.equal(field.value, 'Thu morning');
});

test('a field React controls sees a change on the next input event', () => {
  const field = controlledByReact(new Field());
  setFieldValue(field, 'Thu morning');
  assert.equal(field.value, 'Thu morning');
  assert.equal(field.remembered, '', 'the write went past the property React added');
});

test('an input event, then a change event, both bubbling', () => {
  const field = new Field();
  setFieldValue(field, 'Wed evening');
  assert.deepEqual(field.sent.map((e) => `${e.type}:${e.bubbles}`), ['input:true', 'change:true']);
});

test('the setter is found higher up the prototype chain', () => {
  const field = new SearchField();
  setFieldValue(field, 'kettle');
  assert.equal(field.browserValue, 'kettle');
});

test('a field without a value setter is refused', () => {
  const box = { dispatchEvent() { return true; } };
  assert.throws(() => setFieldValue(box, 'x'), TypeError);
});
A hint

Object.getOwnPropertyDescriptor(proto, 'value') gives undefined for a prototype without the property, and an object with get and set for an accessor. Start at Object.getPrototypeOf(field) and keep calling Object.getPrototypeOf() until you find a descriptor with a set, or run out of prototypes; then call it with set.call(field, value).

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) Put a jQuery handler and a native listener on one button in the playground, add stopPropagation() to one of them, and watch the console.

Then check the four rules on new cases. The session opens on the rule your earlier answers got wrong most often, and a wrong choice says which slip it is:

Practice that adapts to you

Practise this lesson

12 questions about 4 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 Which of these reach both a jQuery handler and a native listener on the document, when called on a button?

    Choose every answer that is right.

    Show the answer: Question 1 of 12

    Answer:

    • button.dispatchEvent(new CustomEvent('cart:changed', { bubbles: true }))
    • button.click()

    Real events cross both ways; triggered custom events stay in jQuery.

  2. Question 2 of 12 emit() is changed to dispatch new CustomEvent(type, { detail }) without bubbles. Who hears cart:changed when the plugin emits it on an Add button?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Only listeners and handlers on that button itself

    bubbles: true is what lets one announcement reach code that listens higher up, which is where delegated jQuery handlers and the badge both listen.

  3. Question 3 of 12 Four scripts announce cart:changed with .trigger(), and six listen to it, some with jQuery and some natively. In what order do you move them?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Move all four announcements to a bubbling CustomEvent first, then move the listeners one at a time

    Announcers first, because a real event serves both worlds; listeners after, each with its own removal.

  4. Question 4 of 12 Which handlers does $(field).off('input') remove?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: The input handlers that were added with jQuery's .on()

    Each API removes only what it added: plan the removal of each binding in the API that made it.

  5. Question 5 of 12 A setup that runs twice calls field.addEventListener('input', search) both times, with the same search function. How many times does search run per key press?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Once: the second call adds nothing

    The panel's arrow functions were new functions at every setup, which is why they piled up; a signal per setup fixes that whatever the function is.

  6. Question 6 of 12 How does the fixed panel make sure a reload never leaves an old listener behind?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Each setup aborts the previous AbortController, makes a new one and passes its signal to addEventListener()

    One AbortController per setup is the native counterpart of a jQuery namespace: one call removes the whole group.

  7. Question 7 of 12 Four listeners handle one click on a button inside a list. Which runs first?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: A listener on the document added with { capture: true }

    Down in the capture phase, the target, then up while it bubbles: the recording logged the five in that order.

  8. Question 8 of 12 The mat's delegated jQuery handler returns false. Which listeners had still run for that click?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: The capture listener on the document and the button's own native listener

    In jQuery, return false calls preventDefault() and stopPropagation(), which stop the rest of jQuery's list and the bubbling above the list.

  9. Question 9 of 12 A product list moves its Remove buttons to native listeners one button type at a time, while a delegated jQuery handler on the list still handles the rest. What keeps each click handled exactly once?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Narrow the delegated selector to the buttons not yet moved, in the same change that adds each native listener

    One owner per element and event, changed in one commit, is what keeps a half-moved list correct.

  10. Question 10 of 12 A jQuery plugin calls $(input).val('Tue morning').trigger('change') on a field that React renders with onChange. Does onChange run?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: No: .trigger() dispatches no real event, and React listens natively

    Frameworks listen with addEventListener(); a plugin talking to them must dispatch real events.

  11. Question 11 of 12 Which way of writing made React call onChange for the picker's value?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: The setter of the input's prototype, then bubbling input and change events

    React's value tracking compares the field's value with the last value written through its property; the prototype's setter is the way past it.

  12. Question 12 of 12 A Vue form binds the field with v-model.lazy. Which events must the plugin dispatch after writing the value?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: A change event, since .lazy syncs after change instead of input

    Dispatching both input and change, as the setter version does, serves plain v-model, v-model.lazy, React and native listeners alike.

Interview questions

Warm-up (2–5 years): why can’t removeEventListener() remove a handler that jQuery’s .on() added?

Because the function you passed to .on() was never added to the element. jQuery adds one listener of its own per element and event type, the first time that type is bound, and keeps your handlers in a list of its own, which it runs from that listener. removeEventListener() needs the same type, the very function that was added and the same capture flag, and only jQuery holds that function, so the call removes nothing. The reverse holds too: .off() removes handlers attached with .on() and never sees native listeners. So during a migration, move a binding and its removal in the same change, and give native listeners an AbortController signal, which removes a whole group at once, as a namespace does in jQuery.

Two harder questions of this module come from this lesson: a search that runs three times per key press, and a React form that ignores a jQuery picker. Work out the cause of each yourself 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

    After the first step of moving a product filter off jQuery, the server logs show each search arriving twice, and three times after a user reloads the filter panel. Tests pass. How do you find the cause and fix it for good?

    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 staff

    A delivery form was rewritten in React, but it still uses the shop's jQuery slot picker. The field shows the chosen slot, yet the form submits an empty slot. Another team's Vue form with the same picker works. Explain and fix it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Real events from dispatchEvent() reach jQuery handlers and native listeners alike, and jQuery handlers read event.detail; a triggered custom event stays inside jQuery and has no detail.
  • .off() removes only handlers added with .on(), and removeEventListener() only native listeners, so a binding and its removal move in the same change, with one AbortController signal per setup.
  • A native listener on an element runs before a delegated jQuery handler on its container, and either can silence the other, so each element has one owner for each event.
  • React and Vue listen natively: .trigger() reaches neither, an input event after input.value = … reaches Vue, and React hears a change only when the value is written through the prototype’s setter.

Terms in this lesson

capture phase
The first part of an event's path: from the window down through the target's ancestors, before the target itself. Only listeners added with { capture: true } run then; jQuery never adds any, so its handlers all run later, while the event bubbles.

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.