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), andremoveAttribute(name). It is always a string, ornullwhen 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.
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.
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.
Identical name, kept in sync
Section titled “Identical name, kept in sync”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 followedelement.setAttribute('id', 'cart');element.id; // 'cart' — and the property followed backUse 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 Propertyclass classNamefor htmlFortabindex tabIndexreadonly readOnlymaxlength maxLengthMulti-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.defaultValueexposing the attribute side so you can still read the original after the field changes.checked(attribute) is the initial checked state;.checkedis the current one, with.defaultCheckedfor the original.selected(attribute) on an<option>is the initial selection;.selectedis the current one, with.defaultSelectedfor 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.
Attribute-only and property-only
Section titled “Attribute-only and property-only”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.
idtitleclassNamehtmlFortabIndexreadOnlyvaluecheckedaria-labeltextContentBoolean attributes: presence, not value
Section titled “Boolean attributes: presence, not value”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 booleanbutton.disabled = true; // disables itbutton.disabled = false; // enables itSo 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, usebutton.removeAttribute('disabled'). Neverbutton.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')));Two traps in one program. First, presence semantics — setting the attribute to the string 'false' still disables the input, so the typed input.disabled property is true. Second, the coercion trap — getAttribute returns the string 'false', so 'false' === 'false' is true, and Boolean('false') is true too, because every non-empty string is truthy. For a boolean attribute, read the typed input.disabled property; never coerce the attribute string.
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.
Enumerated attributes: the browser picks a legal value
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 sawinput.type; // 'text' — the spec's fallback for an unknown typeThe attribute is the raw input, typo and all; the property is the resolved value the runtime acts on.
data-* and the dataset bridge
Section titled “data-* and the dataset bridge”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 stringelement.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-180reads adata-stateattribute 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-testidgives 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.
Reading both sides in DevTools
Section titled “Reading both sides in DevTools”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 attributeWhere the split surfaces in React
Section titled “Where the split surfaces in React”You’ll rarely write this split by hand, but it surfaces in four places in React:
- JSX prop naming.
className,htmlFor, andtabIndexare DOM property names, not React inventions. defaultValuevs.value.defaultValuewrites the attribute (uncontrolled input);valuewrites 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 strayvalue="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' 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.
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.
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.
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.Reveal card-by-card review
External resources
Section titled “External resources”If you want to read the canonical references behind this lesson:
The platform-side reference for the split, including the reflection rule and its boolean, enumerated, and element-reference exceptions.
The forward-looking bridge: className, htmlFor, and the rest of the property names you just learned, in the exact list React expects.
A 2024 deep-dive from a Google web-platform engineer that goes deeper on serialization, type, and how frameworks diverge here.