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).

live
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. value the attribute is the initial value; value the property is the current one. checked, disabled, selected all 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-302LJS-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, style and all SVG attributes go through setAttribute.
  • Identical resolved values are skipped before any DOM write (Object.is).
  • bind, ref and on* are engine directives: consumed, never rendered.
  • Styling conventions for published blocks: lm-<name>-* classes for state, valid data-* attributes for variants.