Forms.
A form is three jobs: hold the values, decide whether they are
acceptable, and submit them exactly once.
$form({ values, rules, onSubmit }) does all three behind a
single reactive handle — and the 43 components in
the Forms group render the result, from a single
Input to a whole MultiStepForm.
What $form is
$form is a reactive form engine backed by one state atom. That
atom holds seven slices — values, errors,
touched, dirty, valid,
submitting and validating — and reads of them
are fine‑grained, exactly like reads of a
$store.
Reach for it as soon as a form has more than one field or any validation at
all. For a single throwaway input, a plain atom is less machinery — see
Forms without $form.
The handle is created once per call site and cached, so it behaves like a store: app‑global, reference‑stable across renders, and the same object no matter how many components read it. The same call‑site rule applies if you declare it inside a component body.
Declaring a form
Bind $form({ … }) to a name at the top level. All three
config keys are optional, but a form without values has nothing
to hold and a form without rules is always valid.
signup = $form({
values: { email: "", password: "" }, // the initial snapshot — also what reset() restores
rules: {
email: [$util.rules.required(), $util.rules.email()],
password: [$util.rules.required(), $util.rules.minLength(8)]
},
onSubmit: (v) => { $welcome = v.email } // only runs when every rule passes
})
values is copied when the form is created, so
that copy is the baseline both reset() and dirty
compare against. The keys of rules matter twice over: they are
the fields validate() checks, and the fields
handleSubmit() marks as touched.
Binding fields
Pass form.values.<field> as a control’s
value and the runtime wires the change handler back into the
form for you — the same implicit two‑way binding a
$state or store reference gets. Pass
form.errors.<field> as error and the field
renders its own message.
$welcome = ""
signup = $form({
values: { email: "", password: "" },
rules: {
email: [$util.rules.required(), $util.rules.email()],
password: [$util.rules.required(), $util.rules.minLength(8)]
},
onSubmit: (v) => { $welcome = v.email }
})
$app(Column([
Input("email", {
label: "Email",
value: signup.values.email,
error: signup.errors.email,
onBlur: () => signup.touch("email")
}),
Input("password", {
label: "Password",
type: "password",
value: signup.values.password,
error: signup.errors.password,
onBlur: () => signup.touch("password")
}),
Button("Create account", { variant: "primary", onClick: () => signup.handleSubmit() }),
$welcome != "" ? Callout("Account created", { tone: "success", description: $welcome }) : Text("Try submitting it empty first.", { variant: "small", tone: "muted" })
], { gap: "md" }))
The onBlur: () => signup.touch("email") line is what makes
the field validate when the user leaves it. Without it the first error a
reader sees arrives on submit, which is later than most people expect.
A bound write does not clear the error
Typing only writes the value. A two‑way binding
routes straight to values.<field>, so a stale message
stays on screen until the next touch,
validateField, validate or
handleSubmit. If you want the message to disappear the moment
the user starts fixing it, drive the field through
form.field(name) instead — its
onChange calls setField, which clears that
field’s error.
One call per field with field()
form.field(name) returns everything one control needs as a
single bag: { name, value, error, onChange, onBlur }. It also
subscribes the current render to that field’s values,
errors and touched slices, so the control
re‑renders when any of the three changes.
contact = $form({
values: { email: "", note: "" },
rules: { email: [$util.rules.required(), $util.rules.email()] }
})
function Field(f, label) {
return Input(f.name, {
label: label,
value: f.value,
error: f.error, // undefined until the field is touched
onChange: f.onChange, // → setField(name, value)
onBlur: f.onBlur // → touch(name): mark touched + validate
})
}
$app(Column([
Field(contact.field("email"), "Email"),
Field(contact.field("note"), "Note (no rules)"),
Text(`dirty: ${contact.dirty} · valid: ${contact.valid}`, { variant: "small", tone: "muted" })
], { gap: "md" }))
Two behaviours come free with the bag. error is
undefined until the field is touched, so a pristine form never
shows red; and value falls back to "" for a field
that values never declared.
Pass the members through explicitly, as above. Wrapping the bag in your own
Field component is the point of it — one call site per
field, and the wiring written once.
Validation rules
rules maps a field name to an array of validators. Every
validator is a function of one value that returns an error message or
null; the array is evaluated in order and the
first message wins. The built‑in set lives on
$util.rules.
| Validator | Fails when | Default message |
|---|---|---|
required(message?) | Value is null, undefined, "", or an empty array. | "This field is required" |
email(message?) | Non‑empty and not x@y.z‑shaped. | "Enter a valid email" |
url(message?) | Non‑empty and not an http/https URL. | "Enter a valid URL" |
min(n, message?) · max(n, message?) | Numeric value below n / above n. | "Must be at least n" / "Must be at most n" |
minLength(n, message?) · maxLength(n, message?) | String length below / above n. | "Must be at least n characters" / "… at most n characters" |
pattern(re, message?) | Non‑empty and the regex does not match. | "Invalid format" |
oneOf(options, message?) | Non‑empty and not in options. | "Not an allowed value" |
matches(other, message?) | Value is not identical to other — the confirm‑password rule. | "Values do not match" |
custom(fn, message?) | fn(value) returns false; a returned string becomes the message. true/null pass. | "Invalid" |
asyncCustom(fn, message?) | Same contract, but fn may return a promise. A rejected promise counts as invalid. | "Invalid" |
The shape validators — email, url,
min/max, minLength/maxLength,
pattern, oneOf — all
pass on an empty value, so an optional field only complains
once the user has typed something.
required, matches, custom and
asyncCustom run against every value, empty included. Put
required() first whenever a field is mandatory.
Regex validators are bounded
pattern runs through the bounded matcher. A
pattern longer than 1024 characters never matches, so the rule always
reports its message; an invalid pattern behaves the same way. The value
under test is truncated at 8192 characters, so a pattern rule
against a very large field only checks its first 8192. The bound exists
because both halves come from untrusted input — see
Resource bounds.
An async validator turns the whole check asynchronous:
validateField returns a promise,
form.validating reads true while any check is in
flight, and a result that resolves after the user has edited the field again
is discarded rather than written.
signup = $form({
values: { handle: "" },
rules: {
handle: [
$util.rules.required(),
$util.rules.minLength(3),
$util.rules.asyncCustom(
(v) => fetch(`https://api.example.com/handles/${v}`).then(r => r.json()).then(d => d.available),
"That handle is taken"
)
]
}
})
$app(Column([
Input("handle", {
label: "Handle",
value: signup.values.handle,
error: signup.errors.handle,
onBlur: () => signup.touch("handle")
}),
signup.validating ? LoadingDots("Checking availability") : Text("")
], { gap: "md" }))
The bare fetch here is a host global, so it stops working under
setGlobalAccessPolicy("safe").
Under a narrowed policy, do the uniqueness check on the server side of your
submit instead.
When validation runs
Nothing validates on its own. Six things move the form forward, and it is
worth knowing which ones write errors, which write
touched, and which update the whole‑form
valid flag.
| Trigger | What it does |
|---|---|
| Typing into a bound control | Writes values.<field> and recomputes dirty. No validation, no error clearing. |
setField(name, value) | Writes the value, clears that field’s error, sets dirty. No validation. |
validateField(name) | Runs that field’s validators and writes errors.<field>. Leaves touched and valid alone. |
touch(name) | Marks touched.<field>, then validateField(name). This is the validate‑on‑blur entry point. |
validate() | Validates every field named in rules, replaces errors wholesale, and updates valid. |
handleSubmit(extra?) | Marks every field in rules touched → validate() → onSubmit(values, extra) when valid. |
valid is the result of the last full validation
Only validate() and handleSubmit() move
it. It starts as true, and a single‑field
touch that finds an error does not flip it — so
disabled: !form.valid on a submit button lets a pristine,
empty form through. Gate on form.dirty instead, or call
form.validate() yourself and branch on what it returns.
Surfacing errors
Per‑field messages come from the error prop, which every
labelled control accepts. For a form long enough that the first failure
scrolls off screen, add ValidationSummary — it renders one
panel at the top, and an entry with a field key becomes a link
that focuses that control.
signup = $form({
values: { email: "", password: "" },
rules: {
email: [$util.rules.required(), $util.rules.email()],
password: [$util.rules.required(), $util.rules.minLength(8)]
}
})
function Problems() {
problems = [
{ label: "Email", message: signup.errors.email, field: "email" },
{ label: "Password", message: signup.errors.password, field: "password" }
].filter(e => e.message)
return problems.length == 0 ? Text("") : ValidationSummary({ errors: problems, count: true })
}
$app(Column([
Problems(),
Input("email", { label: "Email", value: signup.values.email, error: signup.errors.email }),
Input("password", { label: "Password", type: "password", value: signup.values.password, error: signup.errors.password }),
Button("Submit", { variant: "primary", onClick: () => signup.handleSubmit() })
], { gap: "md" }))
errors is an object keyed by field name, so build the summary
list yourself and filter out the fields that passed. count: true
swaps the fixed heading for a counting one
(“There are 2 problems with this form”).
A failure that belongs to the whole form rather than one field —
“Invalid credentials”, “That email is already
registered” — goes on the error prop of
Form, which renders it above the action row. Keep the two
channels separate: field errors come from rules, form errors
come from the server.
Submitting
handleSubmit(extra?) is the one call a submit button needs. It
touches every ruled field so the messages appear, validates, and calls
onSubmit(values, extra) only when nothing failed.
submit(extra?) is an alias for it.
| Situation | handleSubmit() returns |
|---|---|
| A rule failed | false — onSubmit is not called. |
Valid, synchronous onSubmit | true, after onSubmit has run. |
Valid, onSubmit returns a promise | A promise that settles when yours does. |
| Any async rule is involved | A promise of the above. |
submitting is true for the whole submit. A
synchronous onSubmit clears it immediately; one that returns a
promise keeps it true until that promise settles, which is
exactly what a loading button prop wants. If
onSubmit throws, submitting is cleared and the
error propagates to
the program’s error sink.
Submitting to a server
Pair the form with a $mutation
and return its promise from onSubmit. That single
return is what keeps submitting true for the whole
round trip, so the button state needs no extra flag of its own.
createUser = $mutation({
url: "https://api.example.com/users",
method: "POST",
invalidates: ["users"] // refetch any $query keyed "users"
})
signup = $form({
values: { name: "", email: "" },
rules: {
name: [$util.rules.required()],
email: [$util.rules.required(), $util.rules.email()]
},
onSubmit: (v) => createUser.mutate({ body: v }) // returning the promise keeps submitting true
})
$app(Column([
Input("name", { label: "Name", value: signup.values.name, error: signup.errors.name }),
Input("email", { label: "Email", value: signup.values.email, error: signup.errors.email }),
Button("Sign up", {
variant: "primary",
loading: signup.submitting,
disabled: signup.submitting,
onClick: () => signup.handleSubmit()
}),
createUser.error ? Callout("Could not create the account", { tone: "danger", description: "Check the details and try again." }) : Text("")
], { gap: "md" }))
The form’s values object goes out as the JSON body, and
invalidates makes any $query under that key refetch
on success. Validation failures stay on the fields; transport failures land
on createUser.error — two separate surfaces, as they
should be.
Resetting & dirty state
reset() restores the initial values snapshot and
clears errors, touched, dirty,
submitting and validating. It also re‑baselines
what dirty compares against, so a reset form reads clean
immediately.
dirty is a value comparison, not an edit counter: it flips back
to false if the user types the original value back in. That
makes it the right flag for a “Discard” button and for an
unsaved‑changes guard.
$saved = false
feedback = $form({
values: { subject: "", body: "" },
rules: { subject: [$util.rules.required()] },
onSubmit: (v) => {
$saved = true
feedback.reset() // back to the initial values, errors and touched cleared
}
})
$app(Column([
Input("subject", { label: "Subject", value: feedback.values.subject, error: feedback.errors.subject }),
TextArea("body", { label: "Message", rows: 4, value: feedback.values.body }),
Row([
Button("Send", { variant: "primary", onClick: () => feedback.handleSubmit(), disabled: !feedback.dirty }),
Button("Discard", { variant: "ghost", onClick: () => feedback.reset(), disabled: !feedback.dirty })
], { gap: "sm" }),
$saved ? Callout("Thanks — that's on its way.", { tone: "success", compact: true }) : Text("")
], { gap: "md" }))
Both buttons are disabled until something changes, which is why
dirty is the gate here rather than valid. Reset
inside onSubmit only after the write has succeeded —
otherwise a failed request throws away what the user typed.
API reference
Three tables: what you pass in, what you can read, and what you can call.
Everything below hangs off the handle $form({ … })
returns.
Configuration
| Key | Type | Notes |
|---|---|---|
values | object | Initial field values. Copied on creation; that copy is the reset() and dirty baseline. A non‑object is ignored. |
rules | { field: validator[] } | Validators per field, first message wins. These keys also decide which fields handleSubmit() marks touched. |
onSubmit | callable | Called as onSubmit(values, extra) only when validation passes. Return a promise to keep submitting true. |
Reactive state
| Member | Type | Reads |
|---|---|---|
.values | object | Current field values. Two‑way bindable per field: value: form.values.email. |
.errors | object | Message per failing field; a field that passes has no entry. |
.touched | object | true per field the user has left, or that a submit touched. |
.dirty | boolean | Current values differ from the baseline. Flips back to false when they match again. |
.valid | boolean | Result of the last validate() / handleSubmit(). Starts true. |
.submitting | boolean | A submit is in flight. Stays true until an async onSubmit settles. |
.validating | boolean | At least one async validator is pending. |
Methods
| Call | Returns | Effect |
|---|---|---|
.field(name) | { name, value, error, onChange, onBlur } | Everything one control needs, and subscribes the render to that field. error is undefined until touched. |
.setField(name, value) | — | Write one value, clear its error, mark the form dirty. |
.setValues(obj) | — | Shallow‑merge obj into values and mark the form dirty. |
.validateField(name) | string | null, or a promise of it | Validate one field and write its error. |
.validate() | boolean, or a promise of it | Validate every ruled field, replace errors, update valid. |
.touch(name) | — | Mark the field touched and validate it — the onBlur handler. |
.handleSubmit(extra?) · .submit(extra?) | boolean, or a promise | Touch all ruled fields, validate, then onSubmit(values, extra) when valid. |
.reset() | — | Restore the initial values and clear every flag. |
Form shells & layout
Form gives the fields a real <form> around
them, which buys you Enter‑to‑submit, a
type: "submit" button, an aria-busy state from
loading, and one place for a form‑level
error. Wrap each control in FormControl so the
label is associated for you.
login = $form({
values: { email: "", password: "" },
rules: {
email: [$util.rules.required(), $util.rules.email()],
password: [$util.rules.required(), $util.rules.minLength(8)]
},
onSubmit: (v) => { $signedIn = v.email }
})
$signedIn = ""
$app(Form({
onSubmit: () => login.handleSubmit(), // fires on Enter and on the submit Button
loading: login.submitting,
fields: [
FormControl("Email", { field: Input("email", { type: "email", value: login.values.email, error: login.errors.email, onBlur: () => login.touch("email") }) }),
FormControl("Password", { field: Input("password", { type: "password", value: login.values.password, error: login.errors.password, onBlur: () => login.touch("password") }) })
],
buttons: Buttons({ items: [Button("Sign in", { variant: "primary", type: "submit" })] })
}))
Form.onSubmit and form.handleSubmit() are two
different things doing one job: the component reports that the user asked to
submit, the engine decides whether that is allowed. Keep the wiring as thin
as the line above.
| Wrapper | Reach for it when |
|---|---|
FormControl({ label, field, hint, error, required }) | A control needs a label, hint or error slot it does not have itself — Checkbox groups, custom fields. |
FormSection({ label, children, helper }) | Grouping related fields visually. Use it instead of hand‑rolling Card plus SectionHeader. |
FieldSet({ legend, children, helper }) | A real <fieldset> is required — radio and checkbox groups that share one question. |
InputGroup(field, { icon, action, suffix }) | One field needs an adornment: a leading icon, a trailing button, a unit suffix. |
MultiStepForm({ steps, current, onSubmit }) | A wizard. It renders the step indicator, the active step’s content, and the Back/Continue row — no manual step plumbing. |
Choosing the right control
Many of the 43 components in the Forms group exist so that you do not
configure a bare Input into the same shape by hand. The
specialised control ships the icon, the keyboard behaviour and the ARIA
wiring with it.
| Instead of | Use | Because |
|---|---|---|
Input for filtering a list | SearchBar | Magnifier icon and keyboard hint are already there. |
Input({ type: "password" }) | PasswordInput | Show/hide toggle, plus an optional 4‑step strength meter. |
Input({ type: "number" }) | NumberInput | +/− buttons that respect min and max. |
Checkbox for a setting | Switch | A setting reads as on/off, not as a choice being ticked. |
Select over a long list | Combobox | Type‑to‑filter for countries, currencies, users. |
| Several checkboxes for tags | MultiSelect · TagInput | Removable chips bound to one array. |
Input for a code | PinInput | Per‑digit entry for 2FA and SMS codes (length: 6 for OTP). |
Input for a phone or postcode | MaskedInput | Formats against a mask (9 digit, A letter, * any). |
| Two date inputs | DateRangePicker | One shared range, with from and to bound together. |
| Radio buttons for a view mode | ToggleGroup · SegmentedControl | Mutually exclusive filters read better as a segmented row. |
A few controls do not fit $form’s value binding.
FileUpload cannot route a picked File through
state, so wire its action prop to a function declaration
instead; DrawingCanvas and SignaturePad hand you
their data through their own callbacks.
The full signatures for all 43 are in the component reference.
Uncontrolled TextArea and Select
Bind them. The reconciler still wipes typed text in an
uncontrolled TextArea, and loses the option a user picked in
an uncontrolled Select, when a re‑render arrives from
anywhere in the app. Passing value from
form.values.<field> — or from any atom —
avoids it entirely. See
Reactivity for the reconciler contract.
Forms without $form
$form earns its keep from rules and
submitting. A single field with no validation does not need
either: bind a plain atom and you are done, and the same two‑way
binding applies.
$note = ""
$app(Column([
TextArea("note", { label: "Quick note", rows: 3, value: $note }),
Text(`${$note.length} / 280 characters`, { variant: "small", tone: "muted" }),
Button("Save", { variant: "primary", onClick: () => $note = "", disabled: $note == "" })
], { gap: "md" }))
A $store is the middle ground: it
two‑way binds the same way and colocates the actions, but you validate
by hand with $util.rules.validate(value, […]). Move up to
$form when you find yourself tracking touched flags manually.
Rules of thumb
- Declare the form at the top level and name it after what it submits
(
signup,checkout), not after the widget. - Put
required()first in every mandatory field’s rule array — the shape validators all pass on an empty value. - Add
onBlur: () => form.touch("field")to every validated field, or drive it throughform.field(…). Errors that only appear on submit feel late. - Gate the submit button on
submittinganddirty, not onvalid. - Return the promise from
onSubmitsosubmittingcovers the real round trip. - Keep field errors in
rulesand server errors onForm.erroror the mutation’serror. reset()only after the write succeeded.
Next
Global state
The $store primitive $form is built on — persistence, history, and the call‑site rule.
HTTP
Queries, mutations, optimistic updates, and invalidation for the submit half of a form.
Read the guide → LibraryComponents
Every signature in the Forms group, and the other 16 groups alongside it.
View reference →