jQuery 4 (legacy code and migration to DOM APIs) Module 2 – Selecting, reading and changing the page
jQuery .data() vs data-* attributes and dataset
How .data() converts data-* text and keeps its own copy, why it never writes the attribute, the jQuery 4 change to the data object, and moving to dataset.
What you will learn
- Predict what .data() returns for a data-* attribute's text, from a number or a boolean to parsed JSON or the text unchanged
- Map data-* attribute names to .data() keys and dataset properties in both directions
- Explain why .data() and the attribute drift apart after the first read, and fix code that mixes .data() with .attr() or CSS
- Fix code that calls hasOwnProperty() on the object .data() returns in jQuery 4
- Rewrite .data() reads with dataset and explicit parsing, so that each key has one type whatever its text
Before you start
What you’ll learn · Before you start
What you will learn
- Predict what .data() returns for a data-* attribute's text, from a number or a boolean to parsed JSON or the text unchanged
- Map data-* attribute names to .data() keys and dataset properties in both directions
- Explain why .data() and the attribute drift apart after the first read, and fix code that mixes .data() with .attr() or CSS
- Fix code that calls hasOwnProperty() on the object .data() returns in jQuery 4
- Rewrite .data() reads with dataset and explicit parsing, so that each key has one type whatever its text
Before you start
On this page
Two product cards say data-price="1499" and data-price="1499.00". $card.data('price') returns the number 1499
for the first and the text "1499.00" for the second, so price.toFixed(2) works on one card and throws a TypeError
on the other. .data() guesses a type from each attribute’s text, keeps its own copy after the first read, and never
writes anything back to the HTML.
The shop pages of this lesson run in jsdom on jQuery 4.0.0 and 3.7.1, and each run logs what every call returned. You
will predict what .data() makes of an attribute’s text, map attribute names to keys, find the bugs that come from the
copy jQuery keeps, fix the code that jQuery 4’s change to the data object breaks, and move the reads to dataset with
parsing you choose.
.data() converts an attribute’s text, but only when nothing is lost
The value of a data-* attribute: An HTML attribute whose name starts with data-, such as data-count="2", that holds a value for the page's own scripts. Its value is always text: dataset and .attr() return it as written, and jQuery's .data() converts it. is always text. The first time .data(key) reads
data-key, it tries the text against three tests in order, and the first that matches decides the value
(.data()): the words true, false and null become those values; a number becomes a
number only if writing it back gives the same text; text from { to } or from [ to ] goes to JSON.parse().
Anything else, and JSON that does not parse, stays the text it was. Step through four texts from the two cards:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- The kettle's price. The first .data('price') on the kettle card reads data-price as text, "1499", and runs it past the tests in order. The first test that matches decides the value.
- Not a word. "1499" is not one of the words true, false or null, so the next test decides.
- A plain number. +"1499" is 1499, and 1499 written back as text is "1499" again: nothing would be lost, so .data() returns the number 1499.
- The lamp's price. The lamp card says data-price="1499.00". It is not a word either, so it reaches the number test.
- Not plain enough. +"1499.00" is also 1499, but 1499 is written back as "1499", not "1499.00". The text would change, so the number test fails.
- The text stays. "1499.00" does not start with { or [, so .data() returns it unchanged: a string, where the kettle card gave a number for the same key.
- JSON that parses. data-options='{"colour":"steel","litres":1.5}' runs from { to }, and JSON.parse() accepts it, so .data('options') returns an object with two properties.
- JSON that does not. The lamp's {colour: 'teal'} also runs from { to }, but its key has no quotes, so JSON.parse() throws. jQuery catches the error and returns the text: nothing tells the page.
- A word. data-featured="true" matches the first test and becomes the boolean true. The lamp's "yes" matches no test and stays the text "yes".
The page’s script reads nine attributes that both cards have, and logs the text of each next to what .data()
returned for it:
shop/convert.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Product cards</title>
</head>
<body>
<ul id="cards">
<li class="card" id="kettle" data-price="1499" data-sku="7310" data-stock="12" data-featured="true"
data-badge="null" data-weight="1.5" data-barcode="8901234567890"
data-options='{"colour":"steel","litres":1.5}' data-gallery='["front.jpg","side.jpg"]'>Kettle</li>
<li class="card" id="lamp" data-price="1499.00" data-sku="0042" data-stock="012" data-featured="yes"
data-badge="new" data-weight="1e3" data-barcode="12345678901234567890"
data-options="{colour: 'teal'}" data-gallery="front.jpg">Lamp</li>
</ul>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="convert.js"></script>
</body>
</html> shop/convert.js
// convert.js: what .data() returns for the data-* attributes of two product cards that use the same keys, next to
// the text of each attribute, and what dataset returns for the same attributes.
const q = (value) => JSON.stringify(value);
const what = (value) => {
if (value === null) return 'null';
if (Array.isArray(value)) return `${q(value)} (an array)`;
if (typeof value === 'object') return `${q(value)} (an object)`;
if (typeof value === 'string') return `${q(value)} (a string)`;
return `${value} (a ${typeof value})`;
};
const kettle = $('#kettle');
const lamp = $('#lamp');
console.log('key: kettle | lamp, as "the attribute\'s text" gives what .data() returned');
for (const key of ['price', 'sku', 'stock', 'featured', 'badge', 'weight', 'barcode', 'options', 'gallery']) {
const one = `${q(kettle.attr(`data-${key}`))} gives ${what(kettle.data(key))}`;
const two = `${q(lamp.attr(`data-${key}`))} gives ${what(lamp.data(key))}`;
console.log(`${key}: ${one} | ${two}`);
}
// The shop's old code formats every price the same way.
for (const card of [kettle, lamp]) {
try {
console.log(`${card[0].id}: $card.data('price').toFixed(2) is ${card.data('price').toFixed(2)}`);
} catch (error) {
console.log(`${card[0].id}: ${error.name}: ${error.message}`);
}
}
// dataset holds the same attributes as text, always.
const k = document.getElementById('kettle').dataset;
const l = document.getElementById('lamp').dataset;
console.log(`dataset.price: ${q(k.price)} | ${q(l.price)}; dataset.stock: ${q(k.stock)} | ${q(l.stock)}; dataset.featured: ${q(k.featured)} | ${q(l.featured)}`);
console.log(`typeof every dataset value: ${[...new Set([...Object.values(k), ...Object.values(l)].map((v) => typeof v))].join(', ')}`); // Prints what shop/convert.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('shop/convert.html'); Output
key: kettle | lamp, as "the attribute's text" gives what .data() returned
price: "1499" gives 1499 (a number) | "1499.00" gives "1499.00" (a string)
sku: "7310" gives 7310 (a number) | "0042" gives "0042" (a string)
stock: "12" gives 12 (a number) | "012" gives "012" (a string)
featured: "true" gives true (a boolean) | "yes" gives "yes" (a string)
badge: "null" gives null | "new" gives "new" (a string)
weight: "1.5" gives 1.5 (a number) | "1e3" gives "1e3" (a string)
barcode: "8901234567890" gives 8901234567890 (a number) | "12345678901234567890" gives "12345678901234567890" (a string)
options: "{\"colour\":\"steel\",\"litres\":1.5}" gives {"colour":"steel","litres":1.5} (an object) | "{colour: 'teal'}" gives "{colour: 'teal'}" (a string)
gallery: "[\"front.jpg\",\"side.jpg\"]" gives ["front.jpg","side.jpg"] (an array) | "front.jpg" gives "front.jpg" (a string)
kettle: $card.data('price').toFixed(2) is 1499.00
lamp: TypeError: card.data(...).toFixed is not a function
dataset.price: "1499" | "1499.00"; dataset.stock: "12" | "012"; dataset.featured: "true" | "yes"
typeof every dataset value: string
(jQuery 3.7.1 logged the same 14 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node convert.mjs
Each row is one key with two texts, and the type follows the text, not the key. "7310" became a number while
"0042" stayed text, because 42 would lose its zeros; "012" stayed text for the same reason, "1e3" because 1000 is
written "1000", and the 20-digit barcode because a JavaScript number cannot hold 20 digits exactly. The 13-digit
barcode fitted and became a number, which no barcode should be. {colour: 'teal'} failed JSON.parse() because its
key has no quotes, and jQuery returned the text without a word. jQuery 3.7.1 logged the same 14 lines.
Trusting the type that .data() returns
Wrong: $card.data('price').toFixed(2), which works for "1499" and throws for "1499.00". Right: read the
unconverted text with .attr('data-price'), as the documentation advises, or with el.dataset.price, and convert it
yourself with Number(), so every card gives the same type.
Checkpoint
Check: How .data() converts text
data-delivery-slot is deliverySlot to .data() and to dataset
The HTML Standard turns the name of a data-* attribute into a property of dataset: The property of every HTML element that lists its data-* attributes, each name in camelCase without data-: data-delivery-slot is el.dataset.deliverySlot. Its values are always strings, and setting one writes the attribute.: it drops
data-, and each hyphen followed by a lower-case letter goes, with that letter in upper case, so data-delivery-slot
is dataset.deliverySlot
(HTML Standard).
Since jQuery 3, .data() stores every key the same way, in
A way of writing a name of several words without spaces, each word after the first starting with a capital letter, as in deliverySlot.,
so .data('deliverySlot') and .data('delivery-slot') reach the same value
(3.0 upgrade guide). The delivery form of this page has three attributes:
shop/names.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Delivery options</title>
</head>
<body>
<form id="delivery" data-delivery-slot="evening" data-gift-wrap="true" data-pinCode="411001">
<label><input type="radio" name="slot" value="morning"> Morning</label>
<label><input type="radio" name="slot" value="evening" checked> Evening</label>
</form>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="names.js"></script>
</body>
</html> shop/names.js
// names.js: how the names of data-* attributes become .data() keys and dataset properties, and back again.
const q = (value) => JSON.stringify(value);
const form = document.getElementById('delivery');
const $form = $(form);
console.log(`attribute names in the page: ${[...form.attributes].map((a) => a.name).filter((n) => n.startsWith('data-')).join(', ')}`);
console.log(`.data('deliverySlot') ${q($form.data('deliverySlot'))}, .data('delivery-slot') ${q($form.data('delivery-slot'))}, dataset.deliverySlot ${q(form.dataset.deliverySlot)}`);
console.log(`.data('pinCode') ${q($form.data('pinCode'))}, .data('pincode') ${q($form.data('pincode'))}, dataset.pincode ${q(form.dataset.pincode)}`);
console.log(`keys of .data(): ${Object.keys($form.data()).sort().join(', ')}; keys of dataset: ${Object.keys(form.dataset).join(', ')}`);
// Writing through dataset turns each capital letter into a hyphen and the lower-case letter.
form.dataset.giftNote = 'Happy Diwali';
console.log(`after dataset.giftNote = 'Happy Diwali': attribute data-gift-note ${q(form.getAttribute('data-gift-note'))}, .data('giftNote') ${q($form.data('giftNote'))}`);
try {
form.dataset['gift-note'] = 'Happy birthday';
} catch (error) {
console.log(`dataset['gift-note'] = …: ${error.name}`);
} // Prints what shop/names.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('shop/names.html'); Output
attribute names in the page: data-delivery-slot, data-gift-wrap, data-pincode
.data('deliverySlot') "evening", .data('delivery-slot') "evening", dataset.deliverySlot "evening"
.data('pinCode') undefined, .data('pincode') 411001, dataset.pincode "411001"
keys of .data(): deliverySlot, giftWrap, pincode; keys of dataset: deliverySlot, giftWrap, pincode
after dataset.giftNote = 'Happy Diwali': attribute data-gift-note "Happy Diwali", .data('giftNote') "Happy Diwali"
dataset['gift-note'] = …: SyntaxError
(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 names.mjs
The third attribute is written data-pinCode in the HTML, but the page has no such attribute: the parser lowercases
the name of every attribute of an HTML element, as the HTML Standard notes, so it became data-pincode, and
.data('pinCode'), which looks for data-pin-code, found nothing. Its key is pincode in both APIs, and .data()
turned the PIN code into the number 411001: an identifier as a number again. The table sums up the three names:
| In the HTML | .data() key |
dataset property |
|---|---|---|
data-delivery-slot |
deliverySlot, or 'delivery-slot' |
deliverySlot |
data-gift-wrap |
giftWrap |
giftWrap |
data-pinCode, read as data-pincode |
pincode |
pincode |
Writing goes the other way. dataset.giftNote = 'Happy Diwali' created data-gift-note, each capital letter turned
into a hyphen and its lower-case letter, and .data('giftNote') then found it in the attribute. A name that has a
hyphen before a lower-case letter, dataset['gift-note'], is refused with a SyntaxError, as the standard requires.
Checkpoint
Check: Attribute names and keys
.data() reads the attribute once and never writes it back
jQuery’s documentation states both halves: an element’s data-* attributes are read “the first time the data() method
is invoked”, and an update through .data() “does not affect attributes in the DOM”
(.data()). So every value has two copies: the attribute, which CSS, dataset and every
other script read, and jQuery’s store, which .data() reads. Step through the cart badge of this page:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The page loads. The badge says data-count="2". Nothing has asked jQuery for it yet, so jQuery's store for this element is empty.
- The first read. .data('count') finds nothing in the store, so it reads the attribute, turns "2" into the number 2 and keeps that copy. From now on it answers from the copy.
- The attribute changes. Another script sets data-count="3". .attr('data-count') and dataset.count now say 3, but .data('count') still returns 2: it never reads the attribute again.
- Emptied through .data(). The old code empties the cart with .data('count', 0). Only the store changes. The attribute still says "3", so the rule .badge[data-count="0"] never matches and the badge stays on screen.
- Write the attribute. The fix writes the value where everyone reads it: .attr('data-count', 0) or dataset.count = 0. The CSS rule matches, and the badge hides.
- Forget the copy. While old code still reads .data('count'), .removeData('count') drops the copy: the next read takes the attribute again, as the run showed with 7.
The cart page logs the same steps, and two more facts about the store:
shop/stale.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cart</title>
<style>
.badge[data-count="0"] { display: none; }
</style>
</head>
<body>
<a href="/cart/">Cart <span id="cart" class="badge" data-count="2">2</span></a>
<a href="/saved/">Saved <span id="saved" class="badge" data-count="5">5</span></a>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="stale.js"></script>
</body>
</html> shop/stale.js
// stale.js: .data() copies an attribute into jQuery's store the first time it reads it; from then on the attribute and
// the store change separately, and CSS and dataset see only the attribute.
const q = (value) => JSON.stringify(value);
const cart = document.getElementById('cart');
const $cart = $(cart);
console.log(`first read: .data('count') ${q($cart.data('count'))}`);
// Another script, or HTML sent again by the server, changes the attribute.
cart.setAttribute('data-count', '3');
console.log(`after setAttribute('data-count', '3'): .data('count') ${q($cart.data('count'))}, .attr('data-count') ${q($cart.attr('data-count'))}, dataset.count ${q(cart.dataset.count)}`);
// The old code empties the cart through .data(): the store changes, the attribute does not.
$cart.data('count', 0).text('0');
console.log(`after .data('count', 0): the attribute says ${q(cart.getAttribute('data-count'))}; matches .badge[data-count="0"]: ${cart.matches('.badge[data-count="0"]')}`);
// Fix 1: keep the value in one place, the attribute, which CSS and every other script read too.
$cart.attr('data-count', 0);
console.log(`after .attr('data-count', 0): matches .badge[data-count="0"]: ${cart.matches('.badge[data-count="0"]')}; Number(dataset.count) ${Number(cart.dataset.count)}`);
// Fix 2, while old code still reads .data(): drop the stored copy, and the next read takes the attribute again.
cart.setAttribute('data-count', '7');
console.log(`after setAttribute('data-count', '7'): .data('count') ${q($cart.data('count'))}`);
$cart.removeData('count');
console.log(`after .removeData('count'): .data('count') ${q($cart.data('count'))}`);
// The low-level jQuery.data() reads only the store, never the attribute.
const saved = document.getElementById('saved');
console.log(`$.data(saved, 'count') before any .data(): ${q($.data(saved, 'count'))}; $(saved).data('count') ${q($(saved).data('count'))}; $.data(saved, 'count') now ${q($.data(saved, 'count'))}`);
// Where the store lives: in a property of the element object itself.
const own = (el) => Object.keys(el).filter((key) => key.startsWith('jQuery')).length;
console.log(`own properties of the cart element whose name starts with jQuery: ${own(cart)}`);
saved.remove();
console.log(`after saved.remove(), the DOM's own remove(): $.hasData(saved) ${$.hasData(saved)}`);
$cart.remove();
console.log(`after $cart.remove(), jQuery's remove(): $.hasData(cart) ${$.hasData(cart)}`); // Prints what shop/stale.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('shop/stale.html'); Output
first read: .data('count') 2
after setAttribute('data-count', '3'): .data('count') 2, .attr('data-count') "3", dataset.count "3"
after .data('count', 0): the attribute says "3"; matches .badge[data-count="0"]: false
after .attr('data-count', 0): matches .badge[data-count="0"]: true; Number(dataset.count) 0
after setAttribute('data-count', '7'): .data('count') 0
after .removeData('count'): .data('count') 7
$.data(saved, 'count') before any .data(): undefined; $(saved).data('count') 5; $.data(saved, 'count') now 5
own properties of the cart element whose name starts with jQuery: 1
after saved.remove(), the DOM's own remove(): $.hasData(saved) true
after $cart.remove(), jQuery's remove(): $.hasData(cart) false
(jQuery 3.7.1 logged the same 10 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node stale.mjs
After the first read, .data('count') went on returning 2 while the attribute said 3, and after .data('count', 0)
the CSS rule for an empty cart never matched. The lasting fix is one home for the value: write it with
.attr('data-count', 0) or dataset.count = 0, and read it with .attr() or dataset, converting as you need.
.removeData('count') is the stopgap while old code still reads .data(): it drops the copy, so the next read took
the attribute again and returned 7 (.removeData()).
The low-level $.data(el, 'count') reads only the store, and returned undefined until .data() had read the
attribute, as its documentation warns (jQuery.data()). The store is a
property of the element object itself, one whose name starts with jQuery. That is why the DOM’s own remove() left
the data on the removed element, while jQuery’s .remove() deleted it, as it deletes event handlers.
Key idea
Keep each value in one place. When CSS or another script reads it, that place is the attribute, written with
.attr() or dataset; .data() answers from its own copy after the first read and never writes the attribute.
Checkpoint
Check: The copy .data() keeps
In jQuery 4, the object .data() returns has no prototype
.data() without a key returns the element’s whole store as an object. In jQuery 3 that object inherited from
Object.prototype, so data.hasOwnProperty('colour') worked. In jQuery 4 it is a
null-prototype object: An object made with Object.create(null), which inherits nothing, so it has no hasOwnProperty() or toString(). jQuery 4 returns the .data() object in this form; Object.hasOwn(obj, key) and key in obj test it., which inherits nothing. The upgrade guide lists the
change as a fix, so that “event and data keys matching Object.prototype properties are supported”, and names the code
it breaks: calls to hasOwnProperty on the data object
(4.0 upgrade guide). The filters page runs the old check and the two that
replace it:
shop/proto.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Filters</title>
</head>
<body>
<form id="filters" data-colour="red" data-size="m">
<label><input type="checkbox" name="colour" value="red" checked> Red</label>
<label><input type="checkbox" name="size" value="m" checked> Medium</label>
</form>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="proto.js"></script>
</body>
</html> shop/proto.js
// proto.js: the object .data() returns, and the old way of asking it whether a key is there.
const data = $('#filters').data();
console.log(`keys: ${Object.keys(data).sort().join(', ')}; JSON.stringify(data): ${JSON.stringify(data, Object.keys(data).sort())}`);
console.log(`Object.getPrototypeOf(data) === null: ${Object.getPrototypeOf(data) === null}; typeof data.hasOwnProperty: ${typeof data.hasOwnProperty}`);
try {
console.log(`data.hasOwnProperty('colour'): ${data.hasOwnProperty('colour')}`);
} catch (error) {
console.log(`data.hasOwnProperty('colour'): ${error.name}: ${error.message}`);
}
console.log(`'colour' in data: ${'colour' in data}; Object.hasOwn(data, 'colour'): ${Object.hasOwn(data, 'colour')}`);
// Why the prototype went: a key nobody stored must not find a method of Object.prototype.
console.log(`typeof $('#filters').data('toString'), a key nobody stored: ${typeof $('#filters').data('toString')}`); // Prints what shop/proto.js logged in jsdom: on jQuery 4.0.0 and 3.7.1, then on jQuery 4.0.0 with jQuery Migrate
// 4.0.2 (recorded.mjs).
import { show } from './show.mjs';
show('shop/proto.html');
console.log('jQuery 4.0.0 with jQuery Migrate 4.0.2:');
show('shop/proto-migrate.html'); Output
keys: colour, size; JSON.stringify(data): {"colour":"red","size":"m"}
Object.getPrototypeOf(data) === null: true; typeof data.hasOwnProperty: undefined
data.hasOwnProperty('colour'): TypeError: data.hasOwnProperty is not a function
'colour' in data: true; Object.hasOwn(data, 'colour'): true
typeof $('#filters').data('toString'), a key nobody stored: undefined
(jQuery 3.7.1 logged other lines:)
line 2: Object.getPrototypeOf(data) === null: false; typeof data.hasOwnProperty: function
line 3: data.hasOwnProperty('colour'): true
line 5: typeof $('#filters').data('toString'), a key nobody stored: function
jQuery 4.0.0 with jQuery Migrate 4.0.2:
JQMIGRATE: Migrate is installed with logging active, version 4.0.2
keys: colour, size; JSON.stringify(data): {"colour":"red","size":"m"}
(warning) JQMIGRATE: Accessing properties from jQuery.fn.data() inherited from Object.prototype is removed
Object.getPrototypeOf(data) === null: false; typeof data.hasOwnProperty: function
data.hasOwnProperty('colour'): true
'colour' in data: true; Object.hasOwn(data, 'colour'): true
typeof $('#filters').data('toString'), a key nobody stored: function
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node proto.mjs
On 4.0.0 the old check threw TypeError: data.hasOwnProperty is not a function, while 'colour' in data and
Object.hasOwn(data, 'colour') answered true on both versions, the two forms the guide gives. The last line shows why
the prototype had to go: on 3.7.1, .data('toString'), a key nobody stored, returned a function inherited from
Object.prototype; on 4.0.0 it returns undefined. With jQuery Migrate 4.0.2 loaded, the old check works again and
logs the data-null-proto warning once
(warnings.md), which makes the calls easy to find
before Migrate goes.
Search for the checks before the upgrade
Look for hasOwnProperty, toString and valueOf called on what .data() or $.data(el) returned, and replace
each test with Object.hasOwn(obj, key) or key in obj. JSON.stringify() and Object.keys() need no change: the
run printed the same keys and JSON on both versions.
Checkpoint
Check: The data object in jQuery 4
dataset gives text, so parse each key on purpose
dataset never guesses: every value is the attribute’s text, and setting one writes the attribute. Moving a read off
.data() is a decision about each key and the type the code needs: an identifier stays text, a number goes through
Number() and a check, a yes or no compares with one word, and JSON is parsed inside try with a fallback. The same
two cards, read without jQuery:
shop/native.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Product cards</title>
</head>
<body>
<ul id="cards">
<li class="card" id="kettle" data-price="1499" data-sku="7310" data-stock="12" data-featured="true"
data-badge="null" data-weight="1.5" data-barcode="8901234567890"
data-options='{"colour":"steel","litres":1.5}' data-gallery='["front.jpg","side.jpg"]'>Kettle</li>
<li class="card" id="lamp" data-price="1499.00" data-sku="0042" data-stock="012" data-featured="yes"
data-badge="new" data-weight="1e3" data-barcode="12345678901234567890"
data-options="{colour: 'teal'}" data-gallery="front.jpg">Lamp</li>
</ul>
<script src="native.js"></script>
</body>
</html> shop/native.js
// native.js: the two cards of convert.html without jQuery. dataset gives text, and each key is parsed on purpose, so
// its type never depends on how its text happens to be written.
const q = (value) => JSON.stringify(value);
/** A decimal number, or null when the text is not one (no attribute, an empty one, "1,499", "abc"). */
function toNumber(text) {
if (text === undefined || text.trim() === '') return null;
const n = Number(text);
return Number.isFinite(n) ? n : null;
}
/** The parsed JSON, or the fallback when the text is not valid JSON. */
function toJson(text, fallback) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
function readCard(el) {
const d = el.dataset;
return {
price: toNumber(d.price), // a number for sums: "1499" and "1499.00" both give 1499
sku: d.sku, // an identifier: the text as written, leading zeros and all
stock: toNumber(d.stock), // "012" gives 12
featured: d.featured === 'true', // only the word true means yes
options: toJson(d.options, {}), // text that is not JSON gives no options, on purpose
barcode: d.barcode, // digits, but an identifier: 20 digits do not fit in a number
};
}
for (const id of ['kettle', 'lamp']) {
const card = readCard(document.getElementById(id));
console.log(`${id}: ${q(card)}; price.toFixed(2) ${card.price.toFixed(2)}`);
}
// Why toNumber() checks for blank text first: Number() reads an empty or blank text as 0.
console.log(`Number(''): ${Number('')}, Number(' '): ${Number(' ')}, toNumber(''): ${toNumber('')}, toNumber('1,499'): ${toNumber('1,499')}`);
// Writing through dataset writes the attribute, so CSS and every other script see the change.
const kettle = document.getElementById('kettle');
kettle.dataset.stock = 11;
console.log(`after dataset.stock = 11: attribute ${q(kettle.getAttribute('data-stock'))}, matches [data-stock="11"]: ${kettle.matches('[data-stock="11"]')}`);
delete kettle.dataset.badge;
console.log(`after delete dataset.badge: data-badge is still there: ${kettle.hasAttribute('data-badge')}`);
// A value that is not text (a timer, an object) stays out of the HTML: a WeakMap keyed by the element holds it.
const carousels = new WeakMap();
carousels.set(kettle, { slide: 0 });
carousels.get(kettle).slide += 1;
console.log(`carousels.get(kettle) ${q(carousels.get(kettle))}; carousels.has(lamp) ${carousels.has(document.getElementById('lamp'))}`); // Prints what shop/native.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('shop/native.html'); Output
kettle: {"price":1499,"sku":"7310","stock":12,"featured":true,"options":{"colour":"steel","litres":1.5},"barcode":"8901234567890"}; price.toFixed(2) 1499.00
lamp: {"price":1499,"sku":"0042","stock":12,"featured":false,"options":{},"barcode":"12345678901234567890"}; price.toFixed(2) 1499.00
Number(''): 0, Number(' '): 0, toNumber(''): null, toNumber('1,499'): null
after dataset.stock = 11: attribute "11", matches [data-stock="11"]: true
after delete dataset.badge: data-badge is still there: false
carousels.get(kettle) {"slide":1}; carousels.has(lamp) false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node native.mjs
Both prices are now 1499, so toFixed(2) worked on each card, and both SKUs stayed text with their zeros. One value
did change on purpose: .data() returned the lamp’s "yes", which is truthy, so if ($card.data('featured'))
treated the lamp as featured, and the explicit rule counts only "true". Writing the rule down settles what each key
means; a card whose HTML disagrees is then a bug to fix in the HTML. dataset.stock = 11 wrote the attribute, so the
CSS selector matched, and delete dataset.badge removed it.
Number() on its own
Wrong: Number(el.dataset.price), because Number('') and Number(' ') are 0, as the run printed, so an empty
attribute becomes a price of zero. Right: return null for blank text first, then keep the result only if
Number.isFinite() accepts it.
A value that is not text, such as a carousel’s position or a timer, cannot go into an attribute. A
WeakMap: A JavaScript map whose keys are objects it does not keep alive: once nothing else refers to a key, its entry can be reclaimed. Keyed by elements, it holds per-element values that are not text. keyed by the element holds it without keeping the element alive, which is what the
language designed WeakMaps for
(ECMAScript). Answer two questions
about one value your code keeps with .data(), and the table marks where it should live:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Home | Seen by CSS and scripts | Type stays the same | Holds objects |
|---|---|---|---|
| dataset, as text Kept in: the data-* attribute | Good it is the attribute, so [data-sku="0042"] matches | Good always a string, exactly as written | Poor text only: an object becomes "[object Object]" |
| dataset with Number() Kept in: the data-* attribute | Good reads the attribute; write it back with dataset | Good "1499" and "1499.00" both give 1499 | Poor numbers only |
| dataset with JSON.parse() Kept in: the data-* attribute | Good the attribute keeps the JSON text | Mixed an object, or your fallback when the text is not JSON | Mixed plain data only: no functions, no elements |
| A WeakMap per element Kept in: a map in your script | Poor CSS and other scripts never see it | Good keeps any value exactly as stored | Good objects, timers, functions, widget instances |
| jQuery's .data() Kept in: a property of the element | Poor after the first read it answers from a copy CSS never sees | Poor "1499" gives a number, "1499.00" a string | Good any value, kept in a property of the element |
How the answers decide
- “What is the value?” An identifier, such as a SKU or a barcode: rules out dataset with Number(), dataset with JSON.parse() and jQuery's .data(). An identifier keeps its text: 0042 loses its zeros as a number, and .data() turned 7310 into a number but kept 0042 as text.
- “What is the value?” A number used in sums: rules out dataset, as text, dataset with JSON.parse() and jQuery's .data(). Sums need one type: Number() turns "1499" and "1499.00" into 1499, where .data() gave a number for one and a string for the other.
- “What is the value?” Settings written as JSON: rules out dataset, as text and dataset with Number() and adds a caveat to jQuery's .data(). JSON needs JSON.parse() inside try and catch with a fallback; .data() parses valid JSON too, but returns bad JSON as text without a word.
- “What is the value?” An object, a timer or a function: rules out dataset, as text, dataset with Number() and dataset with JSON.parse(). An object, a timer or a function cannot be written into an attribute: dataset would store "[object Object]".
- “Must CSS or another script read the value from the page?” Yes: rules out A WeakMap per element and jQuery's .data(). Only the attribute is shared: CSS never saw the cart count kept by .data(). For an object, share a text state such as data-state="open" and keep the object in a WeakMap.
Checkpoint
Check: dataset with explicit parsing
Practise: read a widget’s settings without jQuery
A delivery widget’s old code read its five settings with .data(). Write the dataset version that gives each key
one type, whatever its text; the sample tests run in your browser:
Exercise · Medium · JavaScript
Read a widget's data-* settings without jQuery
A delivery widget keeps its settings in data-* attributes, and its old code read them with .data(), so each value took whatever type its text happened to suggest. Write readDelivery(el) in delivery.mjs: it reads el.dataset and returns an object whose every key has one type, whatever the text says:
slot: the text as written, ornullwhen the attribute is missing.pincode: the text as written, because a PIN code is an identifier, not an amount;nullwhen it is missing.charge: the delivery charge as a number, so"49"and"49.00"are both49and"49.50"is49.5;nullwhen the attribute is missing, empty, only spaces, or not a number.express:trueonly when the text is exactlytrue; anything else, a missing attribute included, isfalse.hours: the parsed JSON when it is an array, such as[18, 19, 20];[]when the attribute is missing, is not valid JSON, or holds JSON that is not an array.
The tests pass stand-ins for elements: plain objects with a dataset object of strings, as a browser's dataset holds them, so they run without a browser.
Starter code · delivery.mjs
/**
* The delivery widget's settings, read from its data-* attributes, with one type for each key whatever its text.
* @param {{ dataset: Record<string, string | undefined> }} el
*/
export function readDelivery(el) {
const d = el.dataset;
return {
slot: d.slot,
pincode: Number(d.pincode),
charge: parseInt(d.charge),
express: Boolean(d.express),
hours: JSON.parse(d.hours),
};
} The sample tests · delivery.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { readDelivery } from './delivery.mjs';
// A stand-in for an element: dataset holds every data-* attribute as a string, as a browser's dataset does.
const widget = (dataset) => ({ dataset });
test('reads a widget whose attributes are all written plainly', () => {
const el = widget({ slot: 'evening', pincode: '411001', charge: '49', express: 'true', hours: '[18,19,20]' });
assert.deepEqual(readDelivery(el), { slot: 'evening', pincode: '411001', charge: 49, express: true, hours: [18, 19, 20] });
});
test('gives the same types when the text is written another way', () => {
const el = widget({ slot: 'morning', pincode: '110001', charge: '49.00', express: 'yes', hours: '[9, 10]' });
assert.deepEqual(readDelivery(el), { slot: 'morning', pincode: '110001', charge: 49, express: false, hours: [9, 10] });
});
test('keeps the cents of a charge', () => {
assert.equal(readDelivery(widget({ charge: '49.50' })).charge, 49.5);
});
test('gives null, false and [] when the attributes are missing', () => {
assert.deepEqual(readDelivery(widget({})), { slot: null, pincode: null, charge: null, express: false, hours: [] });
});
test('gives null for a charge that is blank or not a number', () => {
for (const charge of ['', ' ', 'abc', '1,499']) assert.equal(readDelivery(widget({ charge })).charge, null, `charge ${JSON.stringify(charge)}`);
});
test('gives [] for hours that are not valid JSON or not an array', () => {
for (const hours of ['{9-12}', '"18"', 'null', '{"from":9}']) assert.deepEqual(readDelivery(widget({ hours })).hours, [], `hours ${JSON.stringify(hours)}`);
}); A hint
Decide each key on its own. Text stays text (?? null covers a missing attribute). For the charge, Number() reads decimals, but Number('') and Number(' ') are both 0, so check for blank text first and use Number.isFinite() on the result. For the hours, call JSON.parse() inside try and catch, then keep the result only if Array.isArray() says it is an array.
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 try the lesson’s own questions, served one after another with your weakest topic first; whichever choice you pick, its reason appears beside it:
Practice that adapts to you
Practise this lesson
13 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 .data(‘count’) and .attr(‘data-count’) differ?
.attr('data-count') reads the attribute as it is now, always as text. .data('count') reads jQuery’s store; the
first time it finds nothing there, it reads the attribute, converts the text (the words true, false and null, a number
that writes back the same, valid JSON) and keeps the result. After that it never reads the attribute again, and
.data('count', 5) changes only the store, so the attribute, dataset and CSS selectors still see the old text. Use
.attr() or dataset for values that CSS or other code reads, and convert them yourself.
The bank of this module has two deeper questions on this lesson: a price that throws on some product cards, and a cart badge that never hides. Draft an answer to each before you open its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A product grid formats every price with $card.data('price').toFixed(2). Most cards work, but the card written data-price="1499.00" throws "toFixed is not a function". Explain the cause, fix it, and say how you keep the same kind of bug out of the rest of the code.
-
Fresher to senior
A cart badge has data-count, and CSS hides .badge[data-count="0"]. The script empties the cart with $badge.data('count', 0), yet the badge stays on screen; later, another script sets data-count="3", and the first script still reads the old count. Explain both bugs and fix them.
Key takeaways
.data()turns an attribute’s text into true, false, null, a number or parsed JSON only when nothing is lost, so the same key gives a number on one element and a string on another, as"1499"and"1499.00"did.data-delivery-slotisdeliverySlotto both.data()anddataset, and the HTML parser lowercases attribute names, sodata-pinCodeis read aspincode..data()reads an attribute once and never writes it back, so keep every value that CSS or another script reads in the attribute, with.attr()ordataset.- In jQuery 4 the object
.data()returns has no prototype: test for a key withObject.hasOwn(data, key)orkey in data, notdata.hasOwnProperty(key). datasetalways gives text: parse each key on purpose, and keep objects and timers in aWeakMapkeyed by the element.
Terms in this lesson
- data-* attribute
- An HTML attribute whose name starts with data-, such as data-count="2", that holds a value for the page's own scripts. Its value is always text: dataset and .attr() return it as written, and jQuery's .data() converts it.
- dataset
- The property of every HTML element that lists its data-* attributes, each name in camelCase without data-: data-delivery-slot is el.dataset.deliverySlot. Its values are always strings, and setting one writes the attribute.
- null-prototype object
- An object made with Object.create(null), which inherits nothing, so it has no hasOwnProperty() or toString(). jQuery 4 returns the .data() object in this form; Object.hasOwn(obj, key) and key in obj test it.
- WeakMap
- A JavaScript map whose keys are objects it does not keep alive: once nothing else refers to a key, its entry can be reclaimed. Keyed by elements, it holds per-element values that are not text.
References
- .data() (OpenJS Foundation (jQuery API documentation))
- jQuery.data() (OpenJS Foundation (jQuery API documentation))
- .removeData() (OpenJS Foundation (jQuery API documentation))
- jQuery Core 3.0 Upgrade Guide: .data() names containing dashes (OpenJS Foundation (jQuery))
- jQuery Core 4.0 Upgrade Guide: Object.prototype pollution fix for data (OpenJS Foundation (jQuery))
- jQuery Migrate: warnings and their meaning (OpenJS Foundation (jQuery))
- HTML Standard: embedding custom non-visible data with the data-* attributes (WHATWG)
- ECMAScript Language Specification: WeakMap Objects (Ecma International (TC39))
- ECMAScript Language Specification: Object.hasOwn (Ecma International (TC39))
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress