Overview

useForm(initialShape) returns { form, updateForm, isValid, validate }. form is a read-tracked proxy (like useState): reading form.guests in a template subscribes to structure changes.

Change field values with form.field.set. Change shape (add/remove rows) only via updateForm((f) => …) — direct assign throws.

isValid() and validate() are sync. Async rules belong on schema / validateObject.

useForm vs useState vs schema

API
Holds
Prefer when
useForm FormAtoms + validate Reactive forms with structural edits
useState Any object bag Local state without form validation
schema Plain snapshot Submit / server; async OK

Nested form

Tracked form proxy. Field .set updates values. isValid() and validate() are sync.

import { html, formAtom, useForm, isRequired, isEmail, minLength, renderApp } from "mates";

const App = () => {
const { form, isValid, validate } = useForm({
name: formAtom("", [isRequired(), minLength(2)]),
email: formAtom("", [isRequired(), isEmail()]),
address: {
street: formAtom("", [isRequired()]),
city: formAtom("", [isRequired(), minLength(2)]),
},
});
const msg = formAtom("");

const hint = (fa) =>
fa.dirty && fa.errors[0]
? html`<span class="tag tag-danger">${fa.errors[0]}</span>`
: "";

return () => html`
<div class="m-col m-gap">
<p class="muted m-t-5">Live isValid: ${isValid()}</p>
<label class="m-col m-gap-xs">
Name
<input .value=${form.name()} @input=${(e) => form.name.set(e.target.value)} />
${hint(form.name)}
</label>
<label class="m-col m-gap-xs">
Email
<input .value=${form.email()} @input=${(e) => form.email.set(e.target.value)} />
${hint(form.email)}
</label>
<label class="m-col m-gap-xs">
Street
<input .value=${form.address.street()} @input=${(e) => form.address.street.set(e.target.value)} />
${hint(form.address.street)}
</label>

Dynamic rows — add / remove

updateForm pushes/splices guest rows. Reading form.guests tracks structure.

import { html, formAtom, useForm, isRequired, isEmail, renderApp } from "mates";

function guestRow() {
return {
name: formAtom("", [isRequired()]),
email: formAtom("", [isRequired(), isEmail()]),
};
}

const App = () => {
const { form, updateForm, isValid, validate } = useForm({
guests: [guestRow()],
});
const msg = formAtom("");

const hint = (fa) =>
fa.dirty && fa.errors[0]
? html`<span class="tag tag-danger">${fa.errors[0]}</span>`
: "";

return () => html`
<div class="m-col m-gap">
<p class="muted m-t-5">updateForm mutates shape. isValid: ${isValid()}</p>
${form.guests.map((g, i) => html`
<div class="m-col m-gap-xs">
<strong>Guest ${i + 1}</strong>
<input placeholder="Name" .value=${g.name()} @input=${(e) => g.name.set(e.target.value)} />
${hint(g.name)}
<input placeholder="Email" .value=${g.email()} @input=${(e) => g.email.set(e.target.value)} />
${hint(g.email)}
<button @click=${() => updateForm((f) => { f.guests.splice(i, 1); })}>Remove</button>
</div>
`)}
<div class="m-flex m-gap-sm m-items-center">
<button @click=${() => updateForm((f) => { f.guests.push(guestRow()); })}>Add guest</button>
<button class="btn-primary" @click=${() => {