Overview

Mates forms split into two tracks. formAtom / useForm are sync reactive fields for the UI (.errors, .isValid, .dirty). schema / validateObject validate plain snapshots and support async validators — use them on submit and on the server.

Do not mix a FormAtom into a schema definition. Prefer await checkValidation(body, schema) from mates-fullstack in handlers. Templates should read isValid() / field.isValid, not call validateObject during render.

Deep pages: formAtom, useForm, validators, schema & combinators, async validation.

Which track

Piece
Input
Notes
formAtom One field (sync) UI binding; validates on set once dirty
useForm Tree of FormAtoms (sync) { form, updateForm, isValid, validate }
schema Plain object (async OK) Shared client/server; nestable
validateObject Snapshot + schema node What schema() calls under the hood

Track 1 — formAtom + validateAll

One formAtom per field. validateAll walks the object, calls validate(), and returns isValid.

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

const App = () => {
const name = formAtom("", [isRequired(), minLength(2)]);
const email = formAtom("", [isRequired(), isEmail()]);
const submitted = formAtom("");

const submit = () => {
const { isValid } = validateAll({ name, email });
submitted.set(isValid ? "Registered " + name() : "");
};

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">
<label class="m-col m-gap-xs">
Name
<input .value=${name()} @input=${(e) => name.set(e.target.value)} />
${hint(name)}
</label>
<label class="m-col m-gap-xs">
Email
<input type="email" .value=${email()} @input=${(e) => email.set(e.target.value)} />
${hint(email)}
</label>
<button class="btn-primary" @click=${submit}>Register</button>
${submitted() ? html`<span class="tag tag-success">${submitted()}</span>` : ""}
</div>
`;
};

renderApp(App, document.getElementById("app"));

Track 2 — schema() on a plain snapshot

atom holds the snapshot. userSchema(data()) on the client is the same function as userSchema(body) on the server.

import { html, atom, schema, isRequired, isEmail, minLength, renderApp } from "mates";

const userSchema = schema((user) => ({
name: [isRequired(), minLength(2)],
email: [isRequired(), isEmail()],
}));

const App = () => {
const data = atom({ name: "", email: "" });
const out = atom("");

return () => html`
<div class="m-col m-gap">
<p class="muted m-t-5">Same schema on client and server — always await.</p>
<input placeholder="Name" .value=${data().name}
@input=${(e) => data.update((d) => { d.name = e.target.value; })} />
<input placeholder="Email" .value=${data().email}
@input=${(e) => data.update((d) => { d.email = e.target.value; })} />
<button class="btn-primary" @click=${async () => {
const r = await userSchema(data());
out.set(JSON.stringify(r, null, 2));
}}>userSchema(data())</button>
${out() ? html`<pre class="muted m-t-5">${out()}</pre>` : ""}
</div>
`;
};

renderApp(App, document.getElementById("app"));