Attributes vs. properties: parsed state vs. live state
How the browser stores element state in two places at once, the HTML attribute the parser captured and the live DOM property the runtime reads and writes, and why telling them apart is the model behind countless React form and hydration bugs.
Here is a small experiment you can run right now. Open any page with a text input, type the word hello into it, and then ask the browser two questions about that same field:
input.value; // 'hello'input.getAttribute('value'); // ''The first call reports what you typed. The second insists the value is empty. And if you open View Source, the page’s HTML still reads <input value="">, exactly as the server sent it. Three views of one input, and they disagree. So which one is telling the truth?
All of them, because they answer different questions. The previous lesson named this gap but didn’t fully explain it: the HTML source is a photograph the parser took once at load time, and the DOM is the running program that has been changing ever since. That idea stayed abstract there. This lesson brings it down to a single piece of data on a single node, and once you see it there, several things start to make sense at once: confusing form bugs, the choice between defaultValue and value you’ll make in React, and the hydration-mismatch errors you’ll see in the console.
You will spend your career writing JSX, not setAttribute, so why learn the raw DOM split at all? Because the abstraction leaks, and it leaks in a small, fixed set of production situations that are hard to read without this model. The goal here isn’t to drill an API you’ll type every day. It’s to give you the vocabulary that makes those leaks make sense when you hit them.
Two slots on every node: the parsed string and the live value
Section titled “Two slots on every node: the parsed string and the live value”Start with the simplest version of the model, before any rules or categories. Many of the data points on a node are stored in two places at once, side by side.
- The attribute is the string the HTML parser read out of the source and recorded the moment 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 there at all. - 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.
Two slots, one node. Every category and pitfall in this lesson follows from that picture, so it’s worth seeing drawn out.
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'.
The synced rows are the expected case: write one side, the other follows. The diverged row is the one to study. The attribute still says "" while the property says 'hello', and a still figure can’t show you why, because the reason plays out over time. The next animation steps through it.
That divergence over time is the core idea. Here is the one-line version.
One term will come up constantly, so it’s worth naming now. When writing one side automatically updates the other, the property and attribute are said to reflect each other. The id and type rows in the figure reflect; the value row 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”Once you accept that an attribute and a property both exist for a given thing, there are only four ways they can relate to each other. An experienced engineer reads any HTML or JSX attribute and knows at a glance which of the four buckets it falls into, and learning to make that call quickly is the point of this lesson. The four cases follow, each with the smallest possible example.
Identical name, kept in sync
Section titled “Identical name, kept in sync”This is the simplest case. The attribute and the property share a name, and they 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 backThe rule here is easy: use the property. It’s typed, it’s live, and element.id = 'cart' reads better than element.setAttribute('id', 'cart'). You almost never touch the attribute side for these.
Renamed properties: why JSX says className
Section titled “Renamed properties: why JSX says className”Sometimes the attribute and property mean the same thing but spell their names differently. The attribute keeps its HTML spelling, while the property gets renamed, usually to camelCase, sometimes to avoid a word JavaScript has reserved. You can’t have a property literally named class or for, because those are keywords, so the DOM picks a legal identifier instead.
The ones worth committing to memory are these.
Attribute Propertyclass classNamefor htmlFortabindex tabIndexreadonly readOnlymaxlength maxLengthThe multi-word HTML attributes (cellpadding, rowspan, and the like) follow the same camelCase rule on the property side. You don’t need to memorize the full list, just the pattern.
Here is where that pays off. Look at this snippet.
<label htmlFor="email" className="block">Email</label>Most newcomers assume React renamed class to className on a whim, decide it’s a React quirk to memorize, and file it under “framework weirdness” without connecting it back to the DOM. The clearer way to see it is this: JSX prop names follow the property side because JSX assigns to properties, not attributes. className and htmlFor are not React inventions. They are the DOM property names you just learned. Because the same names work on both sides, what you learn about the DOM here is already React knowledge, which is why this substrate lesson pays off the moment you start writing React.
Default vs. current: value, checked, selected
Section titled “Default vs. current: value, checked, selected”This is the most consequential pattern, and it’s the one from the opening experiment. For a handful of properties, the attribute and the property deliberately don’t reflect, because they track two genuinely different things:
value(attribute) is the initial value the parser saw;.value(property) is the current live value. The browser also gives you.defaultValue, a property that exposes the attribute side, so you can read the original even after the field has changed.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 attribute is the starting point; the property is where things are now. The moment a user types, clicks a checkbox, or your code runs input.value = '…', the two diverge, which is exactly the divergence you scrubbed through in the typing sequence above. 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, some data lives on only one side, so there’s nothing to reflect.
Property-only. Some properties have no matching attribute at all. textContent, innerHTML, nodeName, and tagName are computed off the live node. There is no textContent="" you could write in HTML, and getAttribute('textContent') returns null because no such attribute exists.
Attribute-only in practice. Going the other way, aria-* is authored and read as attributes. Reflected properties like element.ariaLabel do exist on the platform now, but you don’t reach for that property mirror day to day. Accessibility state is set and inspected with getAttribute('aria-label') and setAttribute('aria-label', '…'), and that’s the form you’ll see in every codebase and tutorial. ARIA gets its own treatment in a later accessibility chapter. For now, just recognize it as the attribute-authored case, with no property bridge you’ll actually reach for.
The one big exception to “attribute-only” is the data-* family, which does get an ergonomic property mirror. Its rename rule earns it a section of its own further down.
Those are the four cases. Before moving on, sort some real attributes into them: doing the recall yourself is what turns the table from something you read 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 is the value
Section titled “Boolean attributes: presence is the value”There’s one rule about a specific group of attributes that trips up nearly everyone at least once, so it earns its own section. For boolean attributes like disabled, checked, readonly, required, hidden, multiple, and selected, the browser cares about exactly one thing: is the attribute present or absent? The value string is ignored entirely.
That means all three of these disable the button:
<button disabled></button><button disabled="disabled"></button><button disabled="false"></button>Read that last one again. disabled="false" disables the button. The string "false" is still a present value, and presence is all that counts. The only way to enable the element is to remove the attribute, not to set it to something that looks falsy.
There’s a second problem hiding in that example. disabled="false" isn’t just ineffective, it’s invalid markup. The HTML spec says a boolean attribute’s only valid values are the empty string or a repeat of its own name, so disabled or disabled="disabled", and an HTML validator will flag disabled="false". Every browser still treats it as present and disables the element anyway. So "false" here fails twice: it doesn’t do what you meant, and it isn’t even legal.
The property side, by contrast, behaves the way your intuition wants: it’s 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.
It helps to make this mistake once on purpose, somewhere safe, so it doesn’t surprise you in a real form later.
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. Coercing an attribute string to a boolean is always a bug for boolean attributes; read the typed input.disabled property instead.
That last line is the dangerous one, because it gives a wrong answer without any error. Reaching for Boolean(element.getAttribute('disabled')) to check whether something is disabled looks reasonable, but it returns true for every present value, since the string came back non-empty. When you want a boolean, read the property, which already is one.
Enumerated attributes: the browser picks a legal value
Section titled “Enumerated attributes: the browser picks a legal value”There is one more variation, lighter than the last. Some attributes don’t accept arbitrary strings. They have a fixed set of legal values, and when the parser sees something outside that set, the property returns a spec-defined fallback instead of the raw string. <input type> is the classic example.
// 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 hands back exactly what was written, typo and all. The property hands back what the browser will actually act on: an unrecognized type falls back to text, so .type reports 'text'. It’s the same idea from a new angle: the attribute is the raw input, and the property is the resolved value the runtime uses.
data-* and the dataset bridge
Section titled “data-* and the dataset bridge”data-* is the one attribute family that ships with a typed, ergonomic property mirror, and it’s one you’ll author constantly. Any attribute you prefix with data- becomes readable through the dataset property, with the prefix stripped and the kebab-case name folded to camelCase.
// 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"Two things to notice. The rename is mechanical: drop data- and turn each -x into X, so data-user-id becomes dataset.userId. And the values are always strings, because underneath it’s still an attribute store, so that '42' never becomes a number on its own.
Why would an experienced engineer reach for data-* in a 2026 React codebase? Three recurring places, and you don’t need to act on any of them yet, just recognize the shape:
- State-driven styling. Tailwind can style off DOM state directly:
data-[state=open]:rotate-180reads adata-stateattribute rather than a piece of React state. Throughout the course, styling that responds to a component’s state is driven by exactly this kind ofdata-*attribute. - Event delegation. A single handler high in the tree can route on
event.target.closest('[data-action]').dataset.action, so one listener serves many buttons, each tagged with what it does. This is the direct setup for the next lesson, where you’ll build delegated handlers anddata-*is the value they route on. - Test selectors.
data-testidgives tests a stable hook that doesn’t break when you restyle. Recognition only for now.
Here’s the loop worth closing: you write data-* in JSX, and you read it as dataset.* in a delegation handler. Same data on the writing side and the reading side, two spellings, with the rename rule as the bridge between them.
Reading both sides in DevTools
Section titled “Reading both sides in DevTools”This is a debugging skill you’ll use often, and it builds straight on the previous lesson’s point that the Elements panel is the live tree, not the source. When you select an element, the panel’s right-hand rail gives you both sides of every slot this lesson described:
- The markup in the Elements tree is the attributes written inside the tag, like
value="". These are the parsed strings, the attribute side. - The Properties subpanel, a tab in the right-hand rail, lists the full live property surface of the selected node (
$0). This is the property side, with real types.
The debugging move follows directly: when an attribute looks correct but the page misbehaves, or the other way around, check the other side. The discrepancy is almost always the attribute/property divergence you now have a name for. Go back to the opening experiment and inspect the input you typed hello into. You’ll see value="" sitting right there in the markup while the Properties subpanel reports value: "hello", which is the whole split laid out in a single inspection.
The fastest manual probe is the Console, using the $0 reference for the last-inspected element:
$0.value; // 'hello' — the live property$0.getAttribute('value'); // '' — the parsed attributeWhere this leaks in a React codebase
Section titled “Where this leaks in a React codebase”Now to bring it together. You learned a raw-DOM split you’ll rarely type by hand, so here is the payoff: the four places it surfaces in React code, each one an error or a decision that’s hard to read without the model you just built. Every one of these is recognition; none of it is React you need to write yet.
- JSX prop naming.
className,htmlFor, andtabIndexare the property side speaking through JSX. They are DOM names you already know, not React inventions. This is the leak you’ve already seen, and it’s first because it’s the one you’ll meet on day one. defaultValuevs.value. This pair decides whether an input is uncontrolled (defaultValuewrites the attribute) or controlled (valuewrites the property). The forms chapter owns the full story; you now know where the two prop names come from.- Hydration-mismatch errors. React renders HTML on the server (the attribute side) and then hydrates against the live DOM on the client (the property side). When the two disagree, say a boolean attribute or a
value/checkedthat differs, React logs a hydration-mismatch error. The previous lesson called a hydration mismatch the snapshot-versus-live gap and left it there; now you can name both parts. The snapshot is the attribute, the live state is the property, and the mismatch is simply the two disagreeing. The App Router chapters own the full hydration story. - A controlled input with a server-rendered
value. React expects to own a controlled input’s value, so a strayvalue="initial"arriving in the server HTML produces a console warning. Recognition only, but now you can read it.
A few quick checks to make sure the recognition landed.
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.