JavaScript Dataset Table
| Piece | What it does | Field note |
|---|---|---|
data-user-id="42" | The HTML side | data-* prefix = legal custom attributes, no framework |
el.dataset.userId | The JS side | camelCase in JS, kebab-case in HTML - the one mapping rule |
delete el.dataset.flag | Remove attribute | Setting = creating; deleting = removing - no null limbo |
CSS [data-state] | Style hooks | Attribute selectors read data-* - state styling without classes |
JSON in data-config | Complex payloads | JSON.parse(el.dataset.config) - parse cost + escaping care |
vs classes for state | Data carries VALUE | class=active is boolean; data-score=87 carries number |
dataset values are STRINGS | The type trap | data-count=5 reads "5" - Number() before arithmetic |
Custom attributes without data- | Invalid | Non-standard attrs break validation - data- is the legal zone |
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
- 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.
- Style off state: [data-state='pending'] { opacity: .6 } - CSS reads data-* attributes directly; the JS toggles one attribute, CSS does the rest.
- 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.