Overview

Async validators are supported only on the plain-object track: schema and validateObject. Signature: async (value, signal?) => string | null.

Pass signal to fetch (or other cancellable I/O). When a newer run starts, Mates aborts the prior AbortSignal so stale responses do not win.

formAtom / useForm are intentionally sync — a Promise result throws. Bind inputs with FormAtoms, then run schema (with async rules) on submit or the server.

Sync vs async

API
Async validators
Signal
formAtom / useForm Throws N/A
schema / validateObject Awaited options.signal or internal

Async rule on schema

Simulated uniqueness — replace setTimeout with fetch(..., { signal }).

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

// Simulated uniqueness check — real code would fetch with signal
const checkIfUsernameAvailable =
(error = "Username already taken") =>
async (username, signal) => {
await new Promise((r) => setTimeout(r, 400));
if (signal?.aborted) return null;
return username.toLowerCase() === "admin" ? error : null;
};

const accountSchema = schema((a) => ({
username: [isRequired(), minLength(3), checkIfUsernameAvailable()],
}));

const App = () => {
const data = atom({ username: "admin" });
const out = atom("");
const busy = atom(false);

const run = async () => {
busy.set(true);
out.set("");
const r = await accountSchema(data());
out.set(JSON.stringify(r, null, 2));
busy.set(false);
};

return () => html`
<div class="m-col m-gap">
<p class="muted m-t-5">Try "admin" (taken) vs any other name.</p>
<input .value=${data().username}
@input=${(e) => data.update((d) => { d.username = e.target.value; })} />
<button class="btn-primary" @click=${run} ?disabled=${busy()}>
${busy() ? "Checking…" : "Validate"}
</button>

AbortSignal — cancel prior run

Pass { signal } to validateObject. Rapid restarts abort the previous validator.

import { html, atom, validateObject, isRequired, renderApp } from "mates";

const App = () => {
const log = atom([]);
let controller = null;

const slow =
() =>
async (_value, signal) => {
log.update((L) => { L.push("start"); });
await new Promise((r) => setTimeout(r, 800));
if (signal?.aborted) {
log.update((L) => { L.push("aborted"); });
return null;
}
log.update((L) => { L.push("done"); });
return null;
};

const kick = () => {
if (controller) controller.abort();
controller = new AbortController();
log.set([]);
validateObject(
{ name: "x" },
{ name: [isRequired(), slow()] },
{ signal: controller.signal },
);
};

return () => html`
<div class="m-col m-gap">
<p class="muted m-t-5">Click twice quickly — first run aborts.</p>
<button class="btn-primary" @click=${kick}>Start validation</button>
<pre class="muted m-t-5">${log().join(" → ") || "(idle)"}</pre>
</div>

formAtom rejects async validators

validate() / set() throw with a clear message.

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

const App = () => {
const asyncRule = () => async () => null;
const field = formAtom("", [isRequired(), asyncRule()]);
const msg = formAtom("");

return () => html`
<div class="m-col m-gap">
<p class="muted m-t-5">
formAtom throws when a validator returns a Promise.
Use schema / validateObject for async rules.
</p>
<button class="btn-danger" @click=${() => {
try {
field.validate();
msg.set("unexpected ok");
} catch (e) {
msg.set(e.message);
}
}}>Call validate()</button>
${msg() ? html`<pre class="muted m-t-5">${msg()}</pre>` : ""}
</div>
`;
};

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