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
onchangefire? 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.