Skip to main content

Form atoms & bindings

mates formAtom + Mates-design controls: validation, bind helpers, and debounced draft/live pairs.

Quick start
import { formAtom, isRequired } from "mates";
import { InputField, bindFormInputField } from "mates-ui";

const email = formAtom("", [isRequired]);
InputField(bindFormInputField(email));

Direct atom binding — the new simplified API

import { html, atom, formAtom, isRequired, isEmail, renderApp } from 'mates';
import { Checkbox, FilledButton, InputField } from 'mates-ui';

const App = () => {
const email = formAtom("", [isRequired, isEmail]);
const age = atom(25);
const agree = formAtom(false, [(v) => v ? null : "Required"]);
return () => html`
<x-col gap="var(--md-space-3)" style="max-width:22.5rem;width:100%;">
${InputField({ atom: email, label: "Email", type: "email" })}
${InputField({ atom: age, label: "Age", type: "number" })}
${Checkbox({ atom: agree, label: "I agree to terms" })}
${FilledButton("Validate all", { onClick: () => { email.validate(); agree.validate(); } })}
</x-col>
`;
};

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

bindFormInputField — string

import { html, formAtom, isRequired, isEmail, renderApp } from 'mates';
import { InputField, bindFormInputField } from 'mates-ui';

const App = () => {
const email = formAtom("", [isRequired, isEmail]);
return () => html`
<div style="max-width:22.5rem;width:100%;">
${InputField(bindFormInputField(email, { label: "Email", type: "email", autocomplete: "email" }))}
</div>
`;
};

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

bindFormInputField — password rules

import { html, formAtom, isRequired, minLength, isPattern, renderApp } from 'mates';
import { InputField, bindFormInputField } from 'mates-ui';

const App = () => {
const password = formAtom("", [isRequired, minLength(8), isPattern(/[0-9]/, "Include a number")]);
return () => html`
<div style="max-width:22.5rem;width:100%;">
${InputField(bindFormInputField(password, { label: "Password", type: "password" }))}
</div>
`;
};

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

bindFormNumberInputField

import { html, formAtom, isRequired, renderApp } from 'mates';
import { InputField, bindFormNumberInputField } from 'mates-ui';

const App = () => {
const age = formAtom(18, [isRequired, (v) => v >= 13 && v <= 120 ? null : "Age must be 13–120"]);
return () => html`
<div style="max-width:12.5rem;width:100%;">
${InputField(bindFormNumberInputField(age, { label: "Age" }))}
</div>
`;
};

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

bindFormTextarea

import { html, formAtom, renderApp } from 'mates';
import { Textarea, bindFormTextarea } from 'mates-ui';

const App = () => {
const bio = formAtom("", [(v) => v.length <= 120 ? null : "Max 120 characters"]);
return () => html`
<div style="max-width:26.25rem;width:100%;">
${Textarea(bindFormTextarea(bio, { label: "Bio", rows: 3, maxLength: 120, showCount: true, helperText: "Validators run on each change." }))}
</div>
`;
};

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

bindFormCheckbox

import { html, formAtom, renderApp } from 'mates';
import { Checkbox, bindFormCheckbox } from 'mates-ui';

const App = () => {
const agree = formAtom(false, [(v) => v ? null : "Turn this on to continue"]);
return () => html`
<x-col gap="var(--md-space-2)">
${Checkbox(bindFormCheckbox(agree, { label: "I agree to the terms (required)" }))}
</x-col>
`;
};

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

bindFormSwitch

import { html, formAtom, renderApp } from 'mates';
import { Switch, bindFormSwitch } from 'mates-ui';

const App = () => {
const marketing = formAtom(true, []);
return () => html`
<x-col gap="var(--md-space-2)">
${Switch(bindFormSwitch(marketing, { label: "Marketing emails" }))}
</x-col>
`;
};

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

bindFormSlider

import { html, formAtom, renderApp } from 'mates';
import { Slider, bindFormSlider } from 'mates-ui';

const App = () => {
const volume = formAtom(40, [(v) => v >= 30 && v <= 80 ? null : "Must be between 30 and 80"]);
return () => html`
<div style="max-width:20rem;width:100%;">
${Slider(bindFormSlider(volume, { label: "Volume", showValue: true, format: (v) => v + "%", min: 0, max: 100, labelSuffixErrors: true }))}
</div>
`;
};

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

validateAll + .get()

import { html, formAtom, validateAll, isRequired, isEmail, minLength, renderApp } from 'mates';
import { FilledButton, InputField, bindFormInputField } from 'mates-ui';

const App = () => {
const email = formAtom("", [isRequired, isEmail]);
const password = formAtom("", [isRequired, minLength(8)]);
const shape = { email, password };
const result = { val: "" };
return () => html`
<x-col gap="var(--md-space-4)" style="max-width:24rem;width:100%;">
${InputField(bindFormInputField(email, { label: "Email", type: "email" }))}
${InputField(bindFormInputField(password, { label: "Password", type: "password" }))}
${FilledButton("Validate", {
onClick: () => {
const r = validateAll(shape);
result.val = r.isValid ? "Valid! email=" + email.get() : "Errors: " + JSON.stringify(r.errors);
email.validate(); password.validate();
},
})}
</x-col>
`;
};

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

Debounced text (draft vs committed)

import { html, minLength, isPattern, renderApp } from 'mates';
import { InputField, FilledButton, OutlinedButton, Text, debouncedFormString, bindDebouncedDraftInputField, resetDebouncedString } from 'mates-ui';

const App = () => {
const pair = debouncedFormString("", 400, [minLength(2), isPattern(/^[a-z0-9-]*$/i, "Letters, numbers, hyphens only")]);
return () => html`
<x-col gap="var(--md-space-3)" style="max-width:25rem;width:100%;">
${InputField(bindDebouncedDraftInputField(pair, { label: "Search slug", placeholder: "e.g. my-api" }))}
${Text("Draft: " + JSON.stringify(pair.draft()), { type: "body-sm", color: "muted" })}
${Text("Committed: " + JSON.stringify(pair.committed()), { type: "body-sm", color: "muted" })}
<x-row gap="var(--md-space-2)">
${FilledButton("Flush + validate", { size: "sm", onClick: () => { pair.flush(); pair.committed.validate(); } })}
${OutlinedButton("Reset", { size: "sm", onClick: () => resetDebouncedString(pair, "") })}
</x-row>
</x-col>
`;
};

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

Debounced slider

import { html, renderApp } from 'mates';
import { Slider, FilledButton, OutlinedButton, Text, debouncedFormNumber, bindDebouncedLiveSlider, resetDebouncedNumber } from 'mates-ui';

const App = () => {
const pair = debouncedFormNumber(50, 350, [(v) => v >= 40 && v <= 60 ? null : "Must be 40–60"]);
return () => html`
<x-col gap="var(--md-space-3)" style="max-width:22.5rem;width:100%;">
${Slider(bindDebouncedLiveSlider(pair, { showValue: true, format: (v) => String(v), min: 0, max: 100, label: "Level" }))}
${Text("Live: " + pair.live(), { type: "body-sm", color: "muted" })}
${Text("Committed: " + pair.committed(), { type: "body-sm", color: "muted" })}
<x-row gap="var(--md-space-2)">
${FilledButton("Flush", { size: "sm", onClick: () => { pair.flush(); pair.committed.validate(); } })}
${OutlinedButton("Reset to 50", { size: "sm", onClick: () => resetDebouncedNumber(pair, 50) })}
</x-row>
</x-col>
`;
};

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

Programmatic .set() / .get()

import { html, formAtom, isRequired, isEmail, renderApp } from 'mates';
import { InputField, OutlinedButton, bindFormInputField } from 'mates-ui';

const App = () => {
const email = formAtom("", [isRequired, isEmail]);
const age = formAtom(18, [isRequired]);
return () => html`
<x-col gap="var(--md-space-4)" style="max-width:24rem;width:100%;">
${InputField(bindFormInputField(email, { label: "Email", type: "email" }))}
<x-row gap="var(--md-space-2)" wrap>
${OutlinedButton("Prefill email", { size: "sm", onClick: () => email.set("hello@example.com") })}
${OutlinedButton("Clear email", { size: "sm", onClick: () => email.set("") })}
${OutlinedButton("Set age to 99", { size: "sm", onClick: () => age.set(99) })}
</x-row>
</x-col>
`;
};

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

Form bindings

import { bindFormInputField, bindFormCheckbox, bindFormSwitch, bindFormSlider, debouncedFormString } from "mates-ui"; import { formAtom, isRequired, isEmail } from "mates";
Prop Type Default Description
bindDebouncedDraftInputField DebouncedFormStringResult → InputFieldOptions Binds the draft atom to a InputField so the UI is always responsive; errors come from the committed atom.
bindDebouncedLiveSlider DebouncedFormNumberResult → SliderOptions Binds the live atom to a Slider; errors come from the committed atom after the debounce fires.
bindFormCheckbox formAtom<boolean> → CheckboxOptions Spreads checked, error and a change handler onto Checkbox.
bindFormInputField formAtom<string> → InputFieldOptions Spreads value, error, errorText and an input handler onto InputField. Pass extra InputFieldOptions as the second argument.
bindFormNumberInputField formAtom<number> → InputFieldOptions Same as bindFormInputField but coerces the input value to a number before writing back to the atom.
bindFormSlider formAtom<number> → SliderOptions Spreads value, error, errorText and an input handler onto Slider.
bindFormSwitch formAtom<boolean> → SwitchOptions Spreads checked, error and a change handler onto Switch.
bindFormTextarea formAtom<string> → TextareaOptions Spreads value, error, errorText and an input handler onto Textarea.
debouncedFormNumber (initial: number, delayMs: number, validators?: ValidatorFn<number>[]) → DebouncedFormNumberResult Creates a live/committed pair for a number. Useful for sliders where you want responsive UI but deferred validation.
debouncedFormString (initial: string, delayMs: number, validators?: ValidatorFn<string>[]) → DebouncedFormStringResult Creates a draft/committed pair for a string. The committed formAtom only receives the value after the debounce delay.
formErrorText formAtom → string | undefined Returns the first validation error string for a formAtom, or undefined when the field is pristine or valid.

Caveats

  • Validators (`isRequired`, `isEmail`, `minLength`, etc.) come from `mates` — not `mates-design`.
  • Call `validateAll(shape)` before form submission to force-validate all fields.
  • `debouncedFormString.flush()` immediately commits the draft without waiting for the timer.