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 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.

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

What you will learn

  • Predict 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:

Interactive · stage by stage How .data() decides what an attribute's text becomes Step through four texts from the two product cards and watch which test decides each value.
How .data() decides what an attribute's text becomes, step 9 of 9. Showing The attribute's text ("1499.00" from data-price), true, false or null? ("true" gives true), A plain number? ("1499.00" fails the test), Wrapped in { } or [ ]? (throws: caught), Otherwise ({colour: 'teal'} stays text). In focus: true, false or null?.The attribute's text"1499.00" from data-pricetrue, false or null?"true" gives trueA plain number?"1499.00" fails the testWrapped in { } or [ ]?throws: caughtOtherwise{colour: 'teal'} stays text

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

Every step as text
  1. 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.
  2. Not a word. "1499" is not one of the words true, false or null, so the next test decides.
  3. 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.
  4. The lamp's price. The lamp card says data-price="1499.00". It is not a word either, so it reaches the number test.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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:

The product cards and convert.js

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(', ')}`);
What convert.js logged JavaScript · convert.mjs
// 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

  1. Question 1 of 2 What does $card.data('stock') return for data-stock="012"?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The text "012"

    The number test is String(+text) === text. Leading zeros, trailing zeros ("1499.00") and exponents ("1e3") all fail it, so the same key gives a number on one element and a string on another.

  2. Question 2 of 2 A card says data-options="{colour: 'teal'}". What does $card.data('options') do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It returns the text {colour: 'teal'} unchanged

    A failed parse is silent: the code that expected an object gets a string. Validate JSON attributes when the HTML is written, and parse them yourself with a fallback.

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:

The delivery form and names.js

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}`);
}
What names.js logged JavaScript · names.mjs
// 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

  1. Question 1 of 2 The HTML says <form data-pinCode="411001">. Which call returns the PIN code?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: .data('pincode')

    HTML attribute names are lowercased when the page is parsed, so a capital letter in a data-* name never reaches the DOM. Write data-* names in lower case with hyphens, as the HTML Standard asks.

  2. Question 2 of 2 el.dataset.giftNote = 'Happy Diwali' creates which attribute?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: data-gift-note

    dataset maps names both ways: data-gift-note is read as giftNote, and giftNote is written as data-gift-note.

.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:

Interactive · step by step One count, two copies Step through the cart badge as scripts read and change its count, and compare the attribute with jQuery's store.
One count, two copies, step 6 of 6. Showing Another script, data-count attribute ("0"), jQuery's store (empty again), CSS and dataset (matches: badge hidden), .data('count') (reads the attribute). In focus: jQuery's store, .data('count').sets itcopied onceAnother scriptdata-count attribute"0"jQuery's storeempty againCSS and datasetmatches: badge hidden.data('count')reads the attribute

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

The cart badge and stale.js

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)}`);
What stale.js logged JavaScript · stale.mjs
// 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

  1. Question 1 of 2 $badge.data('count') returned 2. Another script then runs badge.setAttribute('data-count', '3'). What does $badge.data('count') return now?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The number 2

    jQuery reads data-* attributes the first time .data() is called and then no longer accesses them. To see the new text, read the attribute, or drop the copy with .removeData('count') first.

  2. Question 2 of 2 A badge has data-count="3", and CSS hides .badge[data-count="0"]. The code runs $badge.data('count', 0). Is the badge hidden?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No: only jQuery's store changed, and the attribute still says 3

    Write values that CSS reads with .attr('data-count', 0) or dataset.count = 0: the run matched the rule at once.

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:

The filters form and proto.js

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')}`);
What proto.js logged, then the same page with jQuery Migrate 4.0.2 JavaScript · proto.mjs
// 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

  1. Question 1 of 2 On jQuery 4.0.0, what does $('#filters').data().hasOwnProperty('colour') do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It throws TypeError: … hasOwnProperty is not a function

    The 4.0 upgrade guide names this code as the one that breaks, and gives 'key' in data and Object.hasOwn(data, key) instead.

  2. Question 2 of 2 Which test for the key colour works on the .data() object of jQuery 3 and of jQuery 4?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Object.hasOwn(data, 'colour')

    'colour' in data works too, and also finds inherited keys, which a null-prototype object does not have.

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:

The product cards and native.js, 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'))}`);
What native.js logged JavaScript · native.mjs
// 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:

Interactive · answer and compare Where should each .data() value live without jQuery? Answer two questions about one value your code keeps with .data(), and the table marks the homes that fit it.
What is the value?
Must CSS or another script read the value from the page?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five homes for a value kept with .data(), against who can read it, its type and what it can hold
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

  1. Question 1 of 2 Which way of reading dataset.price gives a number for sums and null for an empty attribute?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: null for blank text, else Number(text), kept only when Number.isFinite() accepts it

    Explicit parsing means deciding what happens to text that is not a number: here null, which the code then handles on purpose.

  2. Question 2 of 2 Old code keeps each carousel's widget instance, an object with methods, with $el.data('carousel', instance). What replaces it without jQuery?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A WeakMap keyed by the element

    Attributes hold text for CSS and other scripts; a WeakMap holds the objects only your script uses.

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, or null when the attribute is missing.
  • pincode: the text as written, because a PIN code is an identifier, not an amount; null when it is missing.
  • charge: the delivery charge as a number, so "49" and "49.00" are both 49 and "49.50" is 49.5; null when the attribute is missing, empty, only spaces, or not a number.
  • express: true only when the text is exactly true; anything else, a missing attribute included, is false.
  • 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.

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.

JSON Formatter Paste a data-* attribute's JSON to see whether JSON.parse() will accept it before your code meets it.

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.

  1. Question 1 of 13 What does .data('featured') return for data-featured="true"?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: The boolean true

    The first test is for the three words; only "true" itself becomes true, and "yes" stays the text "yes".

  2. Question 2 of 13 Why did data-barcode="12345678901234567890" stay a string, while "8901234567890" became a number?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: A number cannot hold 20 digits exactly, so writing it back would change the text

    An identifier made of digits is still an identifier. Read it as text with .attr() or dataset, so no card turns into a number.

  3. Question 3 of 13 A product grid formats prices with $card.data('price').toFixed(2), and some cards throw. Which fix makes every card behave the same, on jQuery 3 and 4?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: Read the text with .attr('data-price'), convert it with Number() after a check, then call toFixed(2)

    The bug is the guess, not the card: code that needs a number should make one, on every element.

  4. Question 4 of 13 Which .data() key reads data-gift-wrap?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: giftWrap

    .data('gift-wrap') reaches the same value, since jQuery 3 camelCases every key.

  5. Question 5 of 13 A form has data-delivery-slot and data-gift-wrap. What keys does the object $form.data() have?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: deliverySlot and giftWrap

    The 3.0 upgrade guide shows the difference: allData.clickCount holds the value, and allData['click-count'] is undefined.

  6. Question 6 of 13 When does .data() read an element's data-* attributes?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: The first time it is asked for a key, and never again while the key is stored

    After the first read the attribute and the store are two copies, and only .attr() and dataset see changes to the attribute.

  7. Question 7 of 13 $.data(saved, 'count') returns undefined although the element has data-count="5". Why?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: jQuery.data() reads only jQuery's store, and nothing has read the attribute into it yet

    Use .data() on a jQuery object when old code relies on the attributes, or read the attribute itself.

  8. Question 8 of 13 A widget is taken out with the DOM's el.remove() and inserted again later. Does $(el).data() still hold its values?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Yes: the store is a property of the element, and the DOM's remove() leaves it

    The same rule decides whether handlers and widgets are cleaned up: jQuery's removal methods clean, the DOM's do not.

  9. Question 9 of 13 Why does jQuery 4 give the .data() object no prototype?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: So that a key named like an Object.prototype property, such as toString, is not answered by an inherited method

    The upgrade guide calls it a fix so that data keys matching Object.prototype properties are supported.

  10. Question 10 of 13 With jQuery Migrate 4.0.2 on jQuery 4.0.0, what does data.hasOwnProperty('colour') do?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: It returns true and logs the data-null-proto warning

    Migrate is how you find the calls; once no page logs the warning, remove it.

  11. Question 11 of 13 What type is every value of el.dataset?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: A string

    Because dataset is always text, every conversion in the code is visible, and the same in every browser.

  12. Question 12 of 13 After the move to text === 'true', the lamp with data-featured="yes" is no longer featured. What should the team do?
    Show the answer: Question 12 of 13

    Answer: Decide what the key means and fix the HTML that disagrees

    Explicit parsing turns hidden guesses into rules; the cards that broke a rule show where the HTML is wrong.

  13. Question 13 of 13 Why is a WeakMap keyed by the element a better home than a global Map for a widget's state?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: A WeakMap does not keep the element alive, so a removed element and its state can be reclaimed

    Keep text that CSS reads in data-* attributes, and objects only your script uses in a WeakMap.

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.

  1. 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.

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

  2. 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.

    Model answer, Pro. First step and follow-ups

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-slot is deliverySlot to both .data() and dataset, and the HTML parser lowercases attribute names, so data-pinCode is read as pincode.
  • .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() or dataset.
  • In jQuery 4 the object .data() returns has no prototype: test for a key with Object.hasOwn(data, key) or key in data, not data.hasOwnProperty(key).
  • dataset always gives text: parse each key on purpose, and keep objects and timers in a WeakMap keyed 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

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.