Reactive attributes
Attributes are reactive by default. Any $ inside one keeps the whole
attribute live:
html`<div class="btn ${active}"> <!-- mixed static + state -->
<input value="${name}" /> <!-- set as a property -->
<input type="checkbox" checked="${on}" /> <!-- boolean property -->
<input disabled /> <!-- bare boolean attribute -->
<lm-datagrid data="${rows}"></lm-datagrid>` <!-- object → element PROPERTY -->
One binding per attribute; one write per change; the full value rebuilt every time. Nothing in between.
Mixed class parts, boolean properties and the optional-attribute idiom (|| false removes it).
import { html } from 'lemonadejs';
const App = (props, { state }) => {
const active = state(false);
const locked = state(false);
const size = state('');
return html`<div>
<button class="btn ${() => (active.value ? 'active' : '')}"
disabled="${locked}"
data-size="${() => size.value || false}"
style="${() => (active.value ? 'background:#ffd43b' : '')}"
onclick="${() => (active.value = !active.value)}">
${() => (active.value ? 'active' : 'inactive')}
</button>
<label><input type="checkbox" bind="${locked}" /> disabled</label>
<select bind="${size}">
<option value="">no data-size</option>
<option value="small">data-size="small"</option>
<option value="large">data-size="large"</option>
</select>
<p><small>Inspect the button in devtools: class, disabled and data-size follow the states.</small></p>
</div>`;
};Background
Attributes sit at the intersection of two long-standing platform complications:
- The attribute/property split. HTML attributes are strings; DOM
properties are typed and live.
valuethe attribute is the initial value;valuethe property is the current one.checked,disabled,selectedall have this dual nature, and each requires a consistent resolution rule. - Partial updates. Systems that update interpolated regions inside
an attribute string can half-update it. LemonadeJS v5 had exactly this
wound: a mixed attribute like
class="test ${x}"could mis-concatenate on update, leaving stale and new fragments combined. Bugs of this class are maddening because the template reads correctly: the corruption exists only at runtime, after the second update. - Rich data and custom elements. Attributes carry strings, so passing an array of 100,000 rows as an attribute is not possible, so someone has to choose the property path. Contract-built custom elements define property accessors for every declared prop so structured data crosses the boundary without serialization.
Mechanics
One binding per attribute, atomic writes. Each attribute with
expressions owns exactly one binding. When a state it reads changes, the
binding re-runs: the full value is rebuilt from all its parts. The
static strings are immutable parser output, the dynamic parts are read
fresh. It is compared with the last value (Object.is), and written in a
single assignment. The template never re-runs, no other binding runs, and
there is no code path that patches a substring of an attribute. The
v5 concatenation bug is not fixed; it is structurally impossible.
Removal and presence have one rule. false, null and undefined
remove the attribute (and reset a same-named boolean property);
true sets it present. The idiom for optional attributes is
${() => cond.value || false}: render it or remove it, never "null"
as text.
Property when one exists. If the element has a same-named property
(value, checked, disabled), the property is set, so form elements
behave as a user expects (the current value changes, not the HTML
default). class and style always go through setAttribute, and SVG
always uses attributes, because that is what the platform defines there.
Objects and functions become element properties, always. A whole-value expression resolving to an object or function is assigned as a property, never stringified:
const rows = store([...]); // 100k row objects
html`<lm-datagrid data="${rows}"></lm-datagrid>`;
// → el.data = rows.value, the ARRAY, by reference. No JSON, no
// stringify, identity preserved.
This is how data crosses into the custom-element deployment: a
contract-built element defines a property accessor for every declared
prop, so el.data = rows lands in the component’s live data state and
the grid updates. Assignments cross this boundary; for in-place mutation
plus touch() semantics, compose inside LemonadeJS
(<${Datagrid} data="${rows}" />), where the state itself is passed by
reference. See Components and
Web components.
Three attribute names are directives, not attributes. bind, ref
and on* are consumed by the engine and never rendered to the DOM:
bind="${state}" wires two-way form binding (LJS-302–LJS-304
validate it; see Two-way binding), ref
receives the created element, and on* attaches handlers
(see Events).
The data-* convention
Published Studio blocks expose state as lm-<name>-* classes and
styling variants as valid data-* attributes, never invented bare
attributes that would fail HTML validation. The Switch root, shipped
source (components/switch):
return html`<label
class="lm-switch ${() => (current.value ? 'lm-switch-on' : 'lm-switch-off')} ${() =>
props.disabled!.value ? 'lm-switch-disabled' : ''} ${() =>
props.size!.value ? 'lm-switch-' + props.size!.value : ''}"
data-position="${() => props.position!.value || false}"
data-color="${() => props.color!.value || false}">
The class attribute mixes one static part and three live expressions:
one binding, rebuilt whole on any change, so the combinations can never
interleave. The variants are CSS-addressable as
[data-color='red'], [data-position='right'], and the || false
idiom removes the attribute entirely when the prop is unset.
In practice
Two ends of the same mechanism, both from shipped blocks:
Live styling, the Switch (above): four dynamic attributes on one
element (a mixed class, two data-* variants), each its own binding.
Toggling the switch re-runs exactly one of them (the class), rebuilds one
string, performs one DOM write. The probe suites assert the rendered
attribute values, which only works because there is no intermediate
half-state to race against.
Rich data, the datagrid: the demo passes 100,000 row objects through
the data attribute position. Inside LemonadeJS the state crosses by
reference and rows.touch() re-renders the visible window; through
<lm-datagrid> the same array lands as an element property assignment.
Either way, zero copies and zero serialization. The receipts are in
State.
Reference
class="a ${x} ${() => y.value}" // mixed parts → ONE binding, atomic rebuild
value="${name}" // property when the element has one
checked="${on}" disabled // booleans: true = present, false/null/undefined = removed
attr="${() => v.value || false}" // optional-attribute idiom
data="${objOrFn}" // objects/functions → element PROPERTY, never stringified
class,styleand all SVG attributes go throughsetAttribute.- Identical resolved values are skipped before any DOM write (
Object.is). bind,refandon*are engine directives: consumed, never rendered.- Styling conventions for published blocks:
lm-<name>-*classes for state, validdata-*attributes for variants.