JavaScript Dataset Table

PieceWhat it doesField note
data-user-id="42"The HTML sidedata-* prefix = legal custom attributes, no framework
el.dataset.userIdThe JS sidecamelCase in JS, kebab-case in HTML - the one mapping rule
delete el.dataset.flagRemove attributeSetting = creating; deleting = removing - no null limbo
CSS [data-state]Style hooksAttribute selectors read data-* - state styling without classes
JSON in data-configComplex payloadsJSON.parse(el.dataset.config) - parse cost + escaping care
vs classes for stateData carries VALUEclass=active is boolean; data-score=87 carries number
dataset values are STRINGSThe type trapdata-count=5 reads "5" - Number() before arithmetic
Custom attributes without data-InvalidNon-standard attrs break validation - data- is the legal zone
Reference: the MDN dataset reference. data-* attributes are the legal custom-attribute zone: data-user-id in HTML reads as el.dataset.userId in JS (kebab to camelCase, the one mapping rule). They carry VALUES where classes only carry booleans - data-score=87 beats class=high - and CSS attribute selectors style off them directly ([data-state=pending]). Bottom line: dataset values are always STRINGS (Number() before arithmetic), delete removes attributes cleanly, and JSON-in-an-attribute works but costs parse time and escaping care - reach for it on static content, prefer JS state for dynamic graphs. Related tools: attributes table (the standard set dataset extends), web components table (observedAttributes consuming data-*), and attribute selectors table (styling off data-*).

data-* attributes are the legal custom-attribute zone: data-user-id in HTML reads as el.dataset.userId in JS - the one mapping rule is kebab-case to camelCase. They carry VALUES where classes only carry booleans - data-score='87' beats class='high'.

Bottom line: dataset values are always STRINGS (Number() before arithmetic), delete removes attributes cleanly (no null limbo), and CSS attribute selectors style off them directly ([data-state='pending']) - state without class juggling.

The honest part: JSON-in-an-attribute works (data-config with JSON.parse) but costs parse time and escaping care - reach for it on static content; dynamic state graphs belong in JS, with dataset as the DOM's reflection of state, not the store itself.

How to use

  1. Bridge HTML and JS: <tr data-user-id='42' data-plan='pro'> read as row.dataset.userId - server-rendered data arrives in the DOM, no script-generated lookups.
  2. Style off state: [data-state='pending'] { opacity: .6 } - CSS reads data-* attributes directly; the JS toggles one attribute, CSS does the rest.
  3. Clean removal: delete el.dataset.temporary - the attribute vanishes (not empty-string limbo), so [data-temporary] selectors stop matching.

Frequently asked questions

What is the kebab-case to camelCase mapping rule?

HTML attribute names are case-insensitive and kebab-cased by convention (data-user-id); the JS dataset property exposes them camelCased (dataset.userId). The conversion is mechanical: every dash followed by a letter becomes that letter uppercased - data-avatar-url becomes dataset.avatarUrl. The traps live at the edges: a leading uppercase in HTML (data-UserID) lowercases to data-userid (dataset.userid, not dataset.userID), and XML-style namespaces (data-x-link) convert oddly. The rule for teams: define data attribute names in kebab-case lowercase always, and the mapping stays invisible.

When are data-* attributes better than classes for state?

When state carries a VALUE. Classes are boolean membership: 'is-active' is on or off. Data attributes carry values: data-score='87', data-step='3', data-state='pending' - and CSS attribute selectors style off the values: [data-state='pending'] { opacity: .6 }, [data-priority='high'] { border-color: red }. The JS wins: one attribute write (el.dataset.state = 'done') replaces class add/remove juggling, and the state is READABLE in devtools and tests as data, not a class-name convention. The rule: classes for styling bundles (many properties change together), data-* for state machines and values the CSS or JS needs to READ.

What are the traps of storing JSON in data attributes?

Three. Escaping: quotes in the JSON close the HTML attribute - attributes need the JSON single-quote-free or HTML-escaped, which is fragile by hand. Parse cost: every read is JSON.parse - fine for static content read once, wasteful in render loops. And mutation invisibility: changing the JS object does not change the attribute - the DOM is not the store, it reflects it (re-serialize and assign explicitly). The working pattern: data-config on STATIC, server-rendered elements (configuration born in HTML), parsed once at init; dynamic state lives in JS, and dataset only mirrors what CSS needs to see (data-state='open', not the whole object).

Why must data-* attributes carry the data- prefix at all?

Namespace law. HTML validates attributes - a non-standard attribute like user-id='42' fails validation and collides with future HTML evolution (attributes HTML might define later). The data- prefix is the RESERVED extension zone: the spec promises HTML will never define data-* attributes, so your names are permanently safe - the same reserved-dash trick custom elements use in their tag names. The platform enforces it loosely (browsers accept anything), but validators, frameworks and collaborators rely on the convention: data-* means 'application data, not platform semantics', which is what makes it readable as an API surface.

Related tools