---
title: "Two-Way Data Binding in LemonadeJS"
description: "Two-way binding in one directive on native elements and one tool inside components: user actions fire onchange, programmatic writes stay silent."
source: https://lemonadejs.com/docs/two-way-binding/
---

# Two-way binding

Two-way binding is one directive on native elements, one tool inside
components, one protocol underneath: **user actions notify, programmatic writes stay silent**.

```javascript
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
```

`bind` on native inputs: the state and the control stay in sync in both directions.

<!--example-->

```js
import { html } from 'lemonadejs';

const App = (props, { state }) => {
    const name = state('Ana');
    const newsletter = state(true);
    const plan = state('pro');
    const bio = state('');

    return html`<div>
        <input bind="${name}" placeholder="name" />
        <label><input type="checkbox" bind="${newsletter}" /> newsletter</label>
        <select bind="${plan}"><option value="free">Free</option><option value="pro">Pro</option></select>
        <br /><textarea bind="${bio}" placeholder="bio" rows="2"></textarea>
        <p>${name} · ${() => (newsletter.value ? 'subscribed' : 'not subscribed')} · plan: ${plan}
           · ${() => bio.value.length} chars</p>
        <button onclick="${() => (name.value = 'Bruno')}">write the state from code</button>
    </div>`;
};
```

## 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):

```javascript
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:

```javascript
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:

```javascript
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:

```javascript
// 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:

```javascript
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:

```javascript
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

```javascript
// 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](/docs/contracts/), declare `bind: Boolean` (or a default
value) and the protocol flows to every deployment, including
[web components](/docs/web-components/), where it surfaces as `el.value`
and the `'change'` event.