Forms

A form is a data shape with inputs attached. form() takes the shape and returns a typed tree of states, ready for bind, with whole-form get and set:

import { form } from 'lemonadejs';

const f = form({ name: '', address: { city: '' }, age: 0 });

html`<input bind="${f.name}" />
     <input bind="${f.address.city}" />
     <input type="number" bind="${f.age}" />`

f.$get();                    // { name, address: { city }, age }, plain data
f.$set({ name: 'Ana' });     // partial apply, nested supported

State-tree model

form() builds the state tree from the shape, and then the states are the storage. There is no backing values object to synchronize, because there is nothing else holding the data:

const f = form({
    name: '',
    address: { city: '', zip: '' },    // nested objects → nested groups, recursively
    tags: [] as string[],              // arrays (and other values) are leaf states
});

f.address.city;                        // State<string>, an ordinary state
  • $get() reconstructs the plain object from the live states on demand (via peek(); reading a form never creates subscriptions). Typing in a bound input wrote the state; there is no second copy that could disagree.
  • $set(values) applies a data object, partial at every level (DeepPartial<T>): f.$set({ address: { city: 'Lisbon' } }) touches one state and nothing else. Unknown keys are ignored, not thrown, so a fat server response applies safely.
  • Fields are ordinary states. Render them live (${f.name}), subscribe() to one field, feed one to computed() for a derived summary, pass one to a component prop. No watch, no valueChanges, no field-registration step.
  • The platform does validation. required, min, pattern, type work on the bound inputs natively; number inputs round-trip real numbers through bind (null when empty), so f.age is a State<number>, never "42".

No per-keystroke form re-render exists because no form render exists: typing updates one state, which updates exactly the bindings that read it.

In practice

A profile form with server round-trip. The whole lifecycle is three calls:

const f = form({ name: '', address: { city: '', zip: '' }, age: 0 });

const Profile = (props, { onMount }) => {
    onMount(() => {
        fetch('/api/profile').then((r) => r.json()).then((data) => f.$set(data));
    });
    return html`<form onsubmit="${(e) => {
        e.preventDefault();
        fetch('/api/profile', { method: 'POST', body: JSON.stringify(f.$get()) });
    }}">
        <input bind="${f.name}" required />
        <input bind="${f.address.city}" />
        <input bind="${f.address.zip}" pattern="[0-9-]+" />
        <input type="number" bind="${f.age}" min="0" />
        <button>Save</button>
    </form>`;
};

The fetch result applies partially and nested; the submit serializes from the live states; native constraint validation gates the inputs.

The other flavor: the Formify block. form() assumes you render the fields. The <Formify /> block is the inverse: a smart <form> wrapper around uncontrolled markup you do not control: server-rendered HTML, a CMS fragment, designer-owned markup. Any child carrying a name participates, including nested bracket names:

html`<${Formify} bind="${data}" url="/api/profile"
    onsubmit="${(data, e) => save(data)}">
    <input name="name" required />
    <input name="address[city]" />          <!-- ⇄ { address: { city } } -->
    <select name="country">...</select>
</${Formify}>`

It collects every named field into one nested data object and applies data back. bind carries the whole form as a single two-way object (user edits flow out evented, external writes flow in silently, per the protocol), and the api adds get/set plus load(url)/save(url) server round-trips. Web-component children participate through their value property, so a published Dropdown inside a Formify just works. Submit stays native: declaring onsubmit intercepts it with (data, event) after constraint validation passes.

Choosing between them: reach for form() when your component renders the fields and you want a typed state per field; it composes with computed, contracts and everything else in the state model. Reach for <Formify /> when the markup already exists or arrives from elsewhere and you want one data object in and out. They meet in the middle: both speak bind, both produce plain nested data.

Reference

import { form } from 'lemonadejs';

const f = form(shape);          // Form<T>, states mirroring the shape
// nested plain objects → nested form groups (recursively)
// arrays and everything else → leaf State<T[K]>

f.field                         // State: bind it, subscribe it, render it
f.group.field                   // nested groups are Form<...> with $get/$set
f.$get()                        // T: plain data, reconstructed via peek()
f.$set(values)                  // DeepPartial<T>: nested partial apply,
                                // unknown keys ignored

type DeepPartial<T>             // partial at EVERY level (arrays kept whole)

form() has no component owner; fields are module-scope states (the same box as store()), so a form can outlive any one component and be shared across several. For the wrapper flavor, the Formify contract is bind (the whole form object), url, onchange(data, previous), onsubmit(data, event), onload(data), and api: { get, set, load, save }.