skip to content

How do you read and write a data-* attribute from JavaScript through an element's dataset property, and what surprises people about the values and the names?

level: middleimportance: should knowfreq 45%

answer

  1. camelCase view over data- attributes
  2. dashes and capitals map both ways
  3. attributes only ever hold strings
  4. "false" is a truthy string
  5. undefined on read, not null

basics

~20 s

The dataset property maps data-* attributes to camelCase keys, so data-user-id is read as el.dataset.userId. Every value is a string, because attributes only hold strings, and a missing key reads as undefined rather than null.

solid answer

~40 s

`el.dataset` is a live map over the element's `data-*` attributes, with the attribute name translated to camelCase: `data-user-id` is `el.dataset.userId`, and assigning `el.dataset.itemCount = 3` writes `data-item-count="3"`. Two things catch people out. First, every value is a string — attributes cannot hold anything else — so `dataset.itemCount + 1` concatenates to `"31"` and you need `Number(...)` or `JSON.parse(...)` to get a typed value back. Second, the name translation runs in reverse when you write, so `dataset.userID` produces the attribute `data-user-i-d`, and a key that already contains a dash followed by a lowercase letter throws a `SyntaxError`. A missing key reads as `undefined`, unlike `getAttribute('data-x')` which returns `null`, and `delete el.dataset.userId` removes the attribute outright.

code

javascript · 11 lines
javascript
const el = document.createElement('div');
el.setAttribute('data-user-id', '42');
el.dataset.itemCount = 3;

console.log(el.dataset.userId);                // "42" (a string)
console.log(el.dataset.itemCount + 1);         // "31" — concatenation
console.log(Number(el.dataset.itemCount) + 1); // 4

console.log(el.dataset.missing);               // undefined
console.log(el.getAttribute('data-missing'));  // null
console.log(el.outerHTML); // <div data-user-id="42" data-item-count="3"></div>

go deeper

for a junior

Know that data-user-id is read as el.dataset.userId and that whatever comes back is a string, so numbers need Number() before you do arithmetic.

for a middle

Explain the two-way name mapping including the reverse case that turns userID into data-user-i-d, the stringification on write, and how undefined-on-missing differs from getAttribute's null.

for a senior

Show the judgment about what belongs in the document at all — small ids and CSS-matchable state tokens yes, structured or private payloads no — and why "false" being truthy is a bug class rather than a curiosity.

for a principal

Own where element-associated state lives across the codebase: what is allowed to be serialized into markup, what stays in JavaScript, and how that line is kept from eroding one convenient attribute at a time.

## What dataset is HTML reserves any attribute whose name starts with `data-` for author-defined data, and the DOM exposes them as one object: `element.dataset`, a `DOMStringMap`. It is a live view, not a snapshot — reading a key reads the attribute right now, writing a key writes the attribute right now, and the two APIs are fully interchangeable: ```js el.dataset.userId === el.getAttribute('data-user-id'); // same storage ``` ## Name translation, in both directions Going from attribute to key, the `data-` prefix is dropped and each `-` that is followed by an ASCII lowercase letter is removed with that letter uppercased. So `data-user-id` → `userId`, `data-x` → `x`, and `data-item-2` → `item-2` — the dash before a digit survives, which means that one is only reachable as `el.dataset['item-2']`. Going from key to attribute, the rule runs backwards: each ASCII uppercase letter becomes `-` plus its lowercase form, and `data-` is prepended. This is the surprising direction: ```js el.dataset.userID = 'x'; el.outerHTML; // <div data-user-i-d="x"></div> el.dataset.userID; // "x" — round-trips consistently el.dataset.userId; // undefined — a different attribute ``` Nothing is wrong there; the mapping is bijective. It just means the attribute you see in DevTools is not the one you expected, and any CSS or server-side code that looks for `data-user-id` will miss it. Pick one canonical spelling and stay with it. One case actually throws: setting a key that contains a `-` immediately followed by a lowercase letter is a `SyntaxError`, because that key could not be produced by the forward mapping and so would not round-trip. ```js el.dataset['my-name'] = 1; // SyntaxError ``` ## Everything is a string An attribute value is a string, full stop, so `dataset` stringifies on write and hands back a string on read: ```js el.dataset.count = 3; typeof el.dataset.count; // "string" el.dataset.count + 1; // "31" — concatenation, not addition Number(el.dataset.count) + 1; // 4 el.dataset.flag = false; el.dataset.flag; // "false" — a truthy string! ``` That last line is the one that reaches production. `if (el.dataset.flag)` is true for `"false"`, because a non-empty string is truthy. Compare explicitly (`=== 'true'`) or store presence rather than a value. Objects fare worse: `el.dataset.user = {id: 1}` stores the string `"[object Object]"`. If you genuinely need structured data in an attribute, serialize deliberately with `JSON.stringify` on the way in and `JSON.parse` on the way out — and accept that the whole payload is now visible in the page source and gets re-parsed on every read. ## Missing keys, and deletion Because `dataset` is an object view, an absent attribute reads as `undefined`. That differs from `getAttribute('data-missing')`, which returns `null`. Both are falsy, so the distinction rarely bites in an `if`, but it matters for `??`, for `=== null` checks, and for anything that distinguishes the two. Deleting works as you would hope: `delete el.dataset.userId` calls `removeAttribute('data-user-id')`. Setting the key to `undefined` does *not* remove it — it writes the string `"undefined"`. ## When to reach for it, and when not to `data-*` is the right tool for small, string-shaped identifiers that genuinely belong to the markup — a row's record id, a variant name, a state token a CSS attribute selector also wants to match on (`[data-state="open"]`). It survives serialization, it is inspectable in DevTools, and it needs no side table. It is the wrong tool for anything large, structured, frequently-changing, or private. The data is in the document: visible to the user, copied by anything that reads the subtree back as HTML, re-stringified on every write, and re-parsed on every read. Rich per-element state is better kept in JavaScript-side structures keyed by the element, where the values keep their types and never touch the markup. `getAttribute('data-...')` remains available and is the escape hatch for names `dataset` cannot express, and for code that wants the `null`-on-missing behaviour.

  • What does if (el.dataset.enabled) evaluate to when the attribute is data-enabled="false"?
    True. The value is the four-character string `"false"`, and every non-empty string is truthy. Compare explicitly with `=== 'true'`, or use presence instead of a value — `el.hasAttribute('data-enabled')` — so there is no string to misread. This is the single most common data-attribute bug.
  • How do you remove a data-* attribute through the dataset API?
    `delete el.dataset.userId`, which is specified to call `removeAttribute('data-user-id')`. Assigning `undefined` or `null` does not remove it — those stringify, leaving the literal attribute values `"undefined"` and `"null"` in the markup, which then read back as truthy strings.
  • You need to attach a large object to each row of a table. Is data-* the right place?
    No. Everything in a `data-*` attribute is serialized into the document, so a JSON blob is stringified on write, parsed on every read, visible in the page source, and carried along by anything that copies the subtree as HTML. Keep the id in the attribute and the object in JavaScript-side state keyed by that id.

saying these in an interview costs you the question

  • dataset values keep their type, so a number stays a number
  • data-* attributes are hidden from the page source
  • dataset.userId and getAttribute('userId') do the same thing
  • You can write a dashed key like dataset.user-id
  • A missing dataset key returns null, like getAttribute

context