Overview

formAtom(initial, validators?) extends atom with sync validation. Validators are a ValidatorFn[] or a factory () => ValidatorFn[] re-read on every check (so rules can close over other fields).

.errors stays [] until the field is dirty or you call validate(). .isValid is a live getter that re-runs validators without writing .errors or marking dirty.

Async validators are not supported — a Promise-returning rule throws. Use schema / validateObject for I/O checks.

formAtom vs atom vs schema

API
Role
Prefer when
formAtom Field + sync validate Inputs with inline errors
atom Plain reactive cell No built-in validation
schema Plain snapshot Submit / server; async OK

Bind inputs to formAtom

set() marks dirty and runs sync validators. Show errors when dirty.

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

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

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 .value=${email()} @input=${(e) => email.set(e.target.value)} />
${hint(email)}
</label>
<div class="m-flex m-flex-wrap m-gap-sm m-items-center">
<span class="tag ${name.isValid && email.isValid ?"tag-success" : "tag-danger"}">
both valid ${name.isValid && email.isValid}
</span>
</div>
</div>
`;
};

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

errors, isValid, dirty, reset

isValid re-runs without writing errors. validate() marks dirty. resetValidation() clears both.

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

const App = () => {
const name = formAtom("", [isRequired(), minLength(3)]);

return () => html`
<div class="m-col m-gap">
<p class="muted m-t-5">Pristine required: errors=[] but isValid is live false.</p>
<label class="m-col m-gap-xs">
Name
<input .value=${name()} @input=${(e) => name.set(e.target.value)} />
</label>
<div class="m-flex m-flex-wrap m-gap-sm m-items-center">
<span class="tag">value "${name()}"</span>
<span class="tag ${name.isValid ?"tag-success" : "tag-danger"}">
isValid ${name.isValid}
</span>
<span class="tag">dirty ${name.dirty}</span>
<span class="tag">errors ${JSON.stringify(name.errors)}</span>
</div>
<div class="m-flex m-gap-sm m-items-center">
<button @click=${() => name.validate()}>validate()</button>
<button @click=${() => name.resetValidation()}>resetValidation()</button>
</div>
</div>
`;
};

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

Cross-field — factory validators

Pass () => [...] so confirm can read password() on every check. isEqualTo takes a getter.

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

const App = () => {
const password = formAtom("", [isRequired(), minLength(8)]);
// Factory list so confirm can close over password() on every check
const confirm = formAtom("", () => [
isRequired(),
isEqualTo(() => password(), "Must match password"),
]);
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">
<label class="m-col m-gap-xs">
Password
<input type="password" .value=${password()} @input=${(e) => password.set(e.target.value)} />
${hint(password)}
</label>
<label class="m-col m-gap-xs">
Confirm
<input type="password" .value=${confirm()} @input=${(e) => confirm.set(e.target.value)} />
${hint(confirm)}
</label>
<button class="btn-primary" @click=${() => {
const { isValid } = validateAll({ password, confirm });
msg.set(isValid ? "Passwords match" : "Fix the fields");
}}>Submit</button>
${msg() ? html`<span class="tag">${msg()}</span>` : ""}
</div>
`;
};

Numeric helpers

toFloat, toFloatString, and toValidNumber parse without changing the atom.

import { html, formAtom, isMin, renderApp } from "mates";

const App = () => {
const price = formAtom("19.999", [isMin(0)]);

return () => html`
<div class="m-col m-gap">
<label class="m-col m-gap-xs">
Price (string in the atom)
<input .value=${price()} @input=${(e) => price.set(e.target.value)} />
</label>
<div class="m-flex m-flex-wrap m-gap-sm m-items-center">
<span class="tag">toFloat() ${price.toFloat()}</span>
<span class="tag">toFloatString() ${price.toFloatString()}</span>
<span class="tag">toValidNumber() ${price.toValidNumber()}</span>
</div>
</div>
`;
};

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