Skip to content
Chapter 14Lesson 2

HTML attributes vs. DOM properties

The browser stores element state twice, in the HTML attribute the parser captured and the live DOM property the runtime reads and writes, and telling them apart explains common React form and hydration bugs.

Open any page with a text input, type hello into it, then ask the browser two questions about that same field:

input.value; // 'hello'
input.getAttribute('value'); // ''

One call reports what you typed; the other says the field is empty. Open View Source and the HTML still reads <input value="">, untouched since the server sent it. Three views of one input, and they disagree. Which is right?

All of them, because they answer different questions. The HTML source is a photograph the parser took once at load time; the DOM is the running program that has been changing ever since. The previous lesson named this gap; here you narrow it to a single piece of data on a single node and watch it directly.

You’ll write JSX, not setAttribute, so why learn the raw DOM split? Because the abstraction leaks, in a small fixed set of production situations you can’t read without this model: confusing form bugs, the choice between defaultValue and value in React, and the hydration-mismatch errors you’ll meet in the console.

Two slots on every node: attribute and property

Section titled “Two slots on every node: attribute and property”

Many values on a DOM node are stored in two places at once, side by side.

  • The attribute is the string the HTML parser read out of the source when it built the node. You reach it with getAttribute(name), setAttribute(name, value), hasAttribute(name), and removeAttribute(name). It is always a string, or null when the attribute isn’t present.
  • The property is a named field on the live element object, with a real type: a string, a boolean, a number, sometimes another object. You reach it with element.propName.

Every category and pitfall in this lesson follows from those two slots.

<input>
Attributes — parsed strings getAttribute(name)
id="email" type="text" value=""
id: 'email' string type: 'text' string value: 'hello' string
Properties — live typed values element.prop
diverged after the user typed

One <input> node, two parallel stores. The attribute shelf holds parsed strings; the property shelf holds live typed values. id and type stay synced; value has diverged, so the attribute still reads "" while the property reads 'hello'.

When you write one side and the other follows, as the id and type rows do, the pair is in its expected state. The value row is the one to study, and a still figure can’t show why it diverges, because the reason plays out over time. The next animation steps through it.

Attribute getAttribute('value') ''
Property input.value ''
Parse time. The browser reads <input value=""> from the source and builds the node. Both slots start equal.
Attribute getAttribute('value') '' left untouched
Property input.value 'hello' changed
The user types hello. The browser updates the live property; it does not rewrite the source it already parsed, so the attribute is left untouched.
Attribute getAttribute('value') '' current / live
Property input.value 'hello' current / live
Read both back. input.value returns the live value; input.getAttribute('value') returns the original parsed string. They disagree, and both are right.
Attribute getAttribute('value') '' = initial / parsed
Property input.value 'hello' = current / live
The attribute is the value the parser saw; the property is the value the field has. They were never tracking the same thing.

When writing one side automatically updates the other, the attribute and property are said to reflect each other. In the figure, id and type reflect; value does not. Whether a given pair reflects is most of what separates the four categories you’re about to learn.

The four ways an attribute and a property relate

Section titled “The four ways an attribute and a property relate”

For any piece of element state, an attribute and a property both exist, and they relate in one of four ways.

The attribute and the property share a name and reflect: write either side and the other follows. id, hidden, lang, and title all work this way.

element.id = 'checkout';
element.getAttribute('id'); // 'checkout' — the attribute followed
element.setAttribute('id', 'cart');
element.id; // 'cart' — and the property followed back

Use the property: it’s typed, live, and element.id = 'cart' reads better than the setAttribute call.

Renamed properties: why JSX says className

Section titled “Renamed properties: why JSX says className”

Here the attribute and property mean the same thing but spell their names differently. The attribute keeps its HTML spelling; the property gets renamed, usually to camelCase, sometimes to dodge a JavaScript keyword. You can’t have a property named class or for, so the DOM picks a legal identifier instead.

Attribute → DOM property
Attribute Property
class className
for htmlFor
tabindex tabIndex
readonly readOnly
maxlength maxLength

Multi-word HTML attributes (cellpadding, rowspan, and the like) follow the same camelCase rule, so learn the pattern, not the full list.

<label htmlFor="email" className="block">Email</label>

className and htmlFor are not React inventions, they are the DOM property names you just learned. JSX prop names follow the property side because JSX assigns to properties, not attributes.

Default vs. current: value, checked, selected

Section titled “Default vs. current: value, checked, selected”

For a handful of properties, the attribute and the property deliberately don’t reflect, because they track two different things: the attribute holds the initial value the parser saw, the property holds the current live one.

  • value (attribute) is the initial value; .value (property) is the current one, with .defaultValue exposing the attribute side so you can still read the original after the field changes.
  • checked (attribute) is the initial checked state; .checked is the current one, with .defaultChecked for the original.
  • selected (attribute) on an <option> is the initial selection; .selected is the current one, with .defaultSelected for the original.

The two diverge the moment a user types, clicks a checkbox, or your code runs input.value = '…': the attribute froze at parse time, the property kept changing.

In the fourth case the data lives on only one side, so there’s nothing to reflect.

Property-only. Some properties have no matching attribute. textContent, innerHTML, nodeName, and tagName are computed off the live node. You can’t write textContent="" in HTML, and getAttribute('textContent') returns null because no such attribute exists.

Attribute-only. Going the other way, aria-* is authored and read as attributes. Reflected properties like element.ariaLabel exist, but in practice you set and inspect accessibility state with getAttribute('aria-label') and setAttribute('aria-label', '…').

The one exception is the data-* family, which does get an ergonomic property mirror; its rename rule earns a section of its own further down.

Sort some real attributes into the four cases. Doing the recall yourself is what turns the table into something you reach for by reflex.

Sort each attribute or property into how its attribute and property side relate. Drag each item into the bucket it belongs to, then press Check.

Identical / synced Same name, reflects both ways
Renamed property Same meaning, different name
Default vs. current Attribute = initial, property = live
One side only No counterpart to reflect
id
title
className
htmlFor
tabIndex
readOnly
value
checked
aria-label
textContent

For boolean attributes like disabled, checked, readonly, required, hidden, multiple, and selected, the browser cares about one thing: is the attribute present or absent? The value string is ignored.

So all three of these disable the button:

<button disabled></button>
<button disabled="disabled"></button>
<button disabled="false"></button>

disabled="false" disables the button, because "false" is a present value and presence is all that counts. The only way to enable the element is to remove the attribute. That third form fails twice: it doesn’t do what you meant, and it’s invalid markup. The HTML spec allows a boolean attribute only the empty string or a repeat of its own name (disabled or disabled="disabled"), so a validator flags disabled="false", though browsers disable the element anyway.

The property side behaves the way your intuition wants, as a real typed boolean:

button.disabled; // false — a real boolean
button.disabled = true; // disables it
button.disabled = false; // enables it

So the two rules are:

  • Raw DOM: set the property, button.disabled = false, because it’s typed and reads correctly. Or, if you must work through the attribute, use button.removeAttribute('disabled'). Never button.setAttribute('disabled', 'false').
  • JSX (recognition only): you pass a boolean, disabled={isLoading}, and React translates that into adding or removing the attribute for you. You never manage presence by hand.

The page contains one text input. Predict the three logged lines. Predict what this program prints, then press Check.

const input = document.querySelector('input');
input.setAttribute('disabled', 'false');
console.log(input.disabled);
console.log(input.getAttribute('disabled') === 'false');
console.log(Boolean(input.getAttribute('disabled')));

The last line is the dangerous one: it gives a wrong answer with no error. Boolean(element.getAttribute('disabled')) looks like a reasonable disabled-check, but every present value comes back as a non-empty string, which is always truthy. When you want a boolean, read the property.

Section titled “Enumerated attributes: the browser picks a legal value”

Some attributes accept only a fixed set of legal values. When the parser sees something outside that set, the property returns a spec-defined fallback instead of the raw string. <input type> is the example to know:

// markup: <input type="numbr"> (typo for "number")
input.getAttribute('type'); // 'numbr' — the raw bytes the parser saw
input.type; // 'text' — the spec's fallback for an unknown type

The attribute is the raw input, typo and all; the property is the resolved value the runtime acts on.

data-* is the one attribute family that ships with its own property mirror, and you’ll reach for it often. Any attribute prefixed with data- is readable through the dataset property, with the prefix dropped and the kebab-case name folded to camelCase: data-user-id becomes dataset.userId.

// markup: <div data-user-id="42">
element.dataset.userId; // '42' — note: still a string
element.dataset.userId = '7'; // updates the data-user-id attribute to "7"

The values are always strings, because underneath it’s still an attribute store, so '42' never becomes a number on its own.

Three recurring places data-* earns its keep in a React codebase:

  • State-driven styling. Tailwind styles off DOM state directly: data-[state=open]:rotate-180 reads a data-state attribute rather than a piece of React state.
  • Event delegation. One handler high in the tree routes on event.target.closest('[data-action]').dataset.action, so a single listener serves many buttons, each tagged with what it does.
  • Test selectors. data-testid gives tests a stable hook that survives a restyle.

You write data-* in JSX and read it as dataset.* in a handler: same data, two spellings, the rename rule between them.

Select an element, and the right-hand rail shows both sides of every slot:

  • The markup in the Elements tree is the attributes written inside the tag, like value="": the parsed strings, the attribute side.
  • The Properties subpanel lists the live property surface of the selected node ($0), with real types.

So when an attribute looks correct but the page misbehaves, or the reverse, check the other side; the discrepancy is almost always the attribute/property divergence. Inspect the input you typed hello into and you’ll see the whole split in one place: the markup shows value="" while Properties reports value: "hello".

The fastest manual probe is the Console, using $0 for the last-inspected element:

$0.value; // 'hello' — the live property
$0.getAttribute('value'); // '' — the parsed attribute

You’ll rarely write this split by hand, but it surfaces in four places in React:

  • JSX prop naming. className, htmlFor, and tabIndex are DOM property names, not React inventions.
  • defaultValue vs. value. defaultValue writes the attribute (uncontrolled input); value writes the property (controlled input).
  • Hydration-mismatch errors. The server renders HTML (the attribute side); the client hydrates against the live DOM (the property side). When they disagree, say a boolean attribute or a differing value/checked, React logs a hydration-mismatch error.
  • A controlled input with a server-rendered value. React expects to own a controlled input’s value, so a stray value="initial" in the server HTML warns in the console.

Check your recognition.

Each statement is about a place the attribute/property split leaks. Mark each one. Mark each statement True or False.

element.setAttribute('disabled', 'false') enables the element.

False. Presence is the value: 'false' is a present value, so the element stays disabled. To enable it, remove the attribute (element.removeAttribute('disabled')) or set the typed property element.disabled = false.

className is a name React invented to avoid a clash with class.

False. className is the DOM property name — class is a reserved word in JavaScript, so the platform picked a legal identifier long before React existed. JSX uses it because JSX assigns to properties, not attributes.

After a user types into an <input>, input.getAttribute('value') returns what they typed.

False. The attribute holds the initial value the parser saw and never moves on its own. What the user typed lives on the property — input.value. (input.defaultValue still exposes that frozen attribute side.)

element.dataset.userId reads the data-user-id attribute, and the value is a string.

True. dataset strips the data- prefix and folds the kebab-case name to camelCase, so data-user-id becomes dataset.userId. It’s an attribute store underneath, so the value is always a string.

If you want to read the canonical references behind this lesson: