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.
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- The badge hears it. The event reaches the document, where the badge's native listener reads event.detail.count and shows 1.
- 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.
- Clear, the other way. The badge's Clear button calls emit() for cart:cleared. A real event, so the jQuery handler on
- 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.
- 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():
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> // 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:
// 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
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:
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> // 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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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):
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> // 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
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- Up to the list. The event bubbles to the list, where jQuery's one native listener hands it to jQuery's dispatcher.
- 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.
- 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:
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> // 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
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:
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);
} // 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- The first fix writes. The picker sets input.value to Wed evening. That goes through React's property, so React now remembers Wed evening.
- 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.
- 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.
- 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.
- 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
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
valuethrough thevaluesetter of the field's prototype chain: the first prototype, going up fromObject.getPrototypeOf(field), that has avalueproperty with a setter. A property on the field itself, such as the one React adds, is skipped; - it throws a
TypeErrorwhen no prototype has such a setter; - then it dispatches an
inputevent and achangeevent, in that order, both made withbubbles: 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).
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.
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.
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.
-
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?
-
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.
Key takeaways
- Real events from
dispatchEvent()reach jQuery handlers and native listeners alike, and jQuery handlers readevent.detail; a triggered custom event stays inside jQuery and has nodetail. .off()removes only handlers added with.on(), andremoveEventListener()only native listeners, so a binding and its removal move in the same change, with oneAbortControllersignal 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, aninputevent afterinput.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
- DOM Standard: interface CustomEvent (WHATWG)
- DOM Standard: addEventListener() and its options (WHATWG)
- DOM Standard: dispatching events (WHATWG)
- .trigger() (OpenJS Foundation (jQuery API documentation))
- .off() (OpenJS Foundation (jQuery API documentation))
- jQuery Core 4.0 Upgrade Guide (OpenJS Foundation (jQuery))
- jQuery 4.0.0 source: src/event.js (OpenJS Foundation (jQuery))
- React: the input component (Meta Open Source (React))
- React source: inputValueTracking.js (Meta Open Source (React))
- Vue.js guide: form input bindings (Vue.js)
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress