Two-way binding

One directive on native elements, one tool inside components, one protocol underneath: user actions notify, programmatic writes stay silent.

const name = state('');
html`<input bind="${name}" />`;          // value ⇄ state, both directions live

html`<${Switch} bind="${enabled}" onchange="${(v) => save(v)}" />`;
// same prop, same semantics, on a published component

Background

Form synchronization has two design challenges:

  • Per-field wiring. Keeping a native form element in sync with a state value typically requires separate declarations for the current value, the update handler, and the state itself.
  • User edits vs programmatic writes. When a parent sets a value into a component programmatically, should the component’s onchange fire? If it does, writes echo back and handlers must filter their own updates. If it does not, the component needs explicit mute logic around every programmatic path. Without a protocol, this is each component author’s responsibility.

How LemonadeJS solves it

Native form elements, an engine directive. bind="${state}" is consumed by the engine; it never appears as a DOM attribute. State to element is a binding; element to state listens to input (or change for checkbox, radio and select):

const name = state('');
html`<input bind="${name}" />                          <!-- value ⇄ string -->
     <input type="checkbox" bind="${on}" />            <!-- checked ⇄ boolean -->
     <input type="radio" value="a" bind="${pick}" />   <!-- groups share one state -->
     <select bind="${choice}">...</select>
     <textarea bind="${text}"></textarea>`

The directive is validated, because the mistakes are predictable: bind requires the state object; bind="name" (a string) or bind="${name.value}" (a snapshot) fail loudly with LJS-302. Only input, textarea and select accept it (LJS-303 elsewhere), and combining it with an explicit value/checked warns with LJS-304, since bind owns those properties. Two details the engine handles so you never think about them: element props apply after children, so a <select> bind sees its options and the initial value lands correctly; and number/ range inputs round-trip real numbers (null when empty), so a State<number> never silently holds a string.

Components: the protocol and the bind() tool. On a component, bind is a plain prop with no engine magic. The component implements the protocol with one line:

const Switch = (props, { bind }) => {
    const checked = bind(props, false);   // external state if bound, local otherwise
    return html`<div class="${() => (checked.value ? 'on' : 'off')}"
        onclick="${() => checked.set(!checked.value)}"></div>`;
};

One prop covers three consumer shapes:

html`<${Switch} bind="${enabled}" onchange="${(v) => ...}" />`  // two-way
html`<${Switch} bind="${true}" />`                              // plain value = initial only
html`<${Switch} />`                                             // standalone: local state

set() vs .value: the entire protocol. The state bind() returns has one extra method. checked.set(v) commits a component-initiated change: it writes the value and fires the consumer’s onchange. Plain checked.value = v writes silently, so when the parent updates the bound state programmatically, the change flows in without echoing onchange back. User actions are evented; external writes are silent. No mute flags, no echo filtering, by construction.

And because the protocol is a contract field, it survives every deployment: a published component’s bind becomes the custom element’s value property plus a real 'change' CustomEvent (el.value = x in, addEventListener('change', ...) out), so the same component binds identically under a lemonade parent, in React through the adapter, or as a bare web component in anything else.

In practice

Switch: three properties that look alike and aren’t. The block keeps all three on purpose, because they answer different questions:

// bind     = the live two-way state (wins when present)
// checked  = the INITIAL state when unbound
// value    = the string submitted with the form when on (plain DOM semantics)
const current = bind(props, props.checked!.value);

html`<input type="checkbox" checked="${current}"
    onchange="${(e) => current.set(e.target.checked)}" ... />`

The suite pins the semantics: bind takes precedence over checked; a click writes through to the bound store and fires onchange; an external on.value = false flips the switch silently; onchange never sees it.

Dropdown: escape cancels, everything else commits. The dropdown keeps an uncommitted selection while open. Closing decides its fate:

const close = (origin) => {
    if (origin === 'escape') {
        applyValue(picked.peek());   // cancel: restore the committed value
    } else {
        picked.set(next);            // commit: fires onchange once, if changed
    }
};

Meanwhile external writes land through picked.subscribe(...): the parent can set the value at any time, the label and selection update, and no onchange echoes back. One bindable state carries the whole lifecycle.

Modal: bind as the controlled open state. The modal’s bind is its open flag, so visibility is just data:

const visible = store(false);
html`<${Modal} bind="${visible}" title="Settings">...</${Modal}>`;

visible.value = true;    // opens, silently, no onclose/onchange echo
// user clicks the close button → the modal calls open.set(false):
// visible is false in YOUR store, and the evented path fired

Declared props stay live while open, too: the v5 “reactive properties” demo survives as a test: assign position.value = 'right' on an open modal and it re-places itself under the new positioning model.

Reference

// Native elements (engine directive: consumed, never an attribute)
html`<input bind="${state}" />`        // input | textarea | select only
// LJS-302: bind needs the state object (not a string, not .value)
// LJS-303: bind on a non-form element
// LJS-304: bind owns value/checked, so drop the explicit attribute

// Components (the bind() tool)
const v = bind(props, fallback);       // Bound<T>
v.value = x                            // silent write, no onchange
v.set(x)                               // evented write, fires props.onchange(x, old)
// props.bind is a State  → v delegates to it (two-way)
// props.bind is a value  → local state, initialized from it
// props.bind is absent   → local state, initialized from fallback

// The shipped types
type Bindable<T> = { bind?: State<T> | T; onchange?: (value: T, old: T) => void };
interface Bound<T> extends State<T> { set(value: T): void }

In a contract, declare bind: Boolean (or a default value) and the protocol flows to every deployment, including web components, where it surfaces as el.value and the 'change' event.