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 (viapeek(); 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. Nowatch, novalueChanges, no field-registration step. - The platform does validation.
required,min,pattern,typework on the bound inputs natively; number inputs round-trip real numbers throughbind(nullwhen empty), sof.ageis aState<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 }.