Behaviour

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.

Live — submit it empty, then fix one field at a time
$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.

Live — the error appears on blur, then clears as you type
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.

ValidatorFails whenDefault 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.

TriggerWhat it does
Typing into a bound controlWrites 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.

Live — press Submit to raise the summary
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.

SituationhandleSubmit() returns
A rule failedfalseonSubmit is not called.
Valid, synchronous onSubmittrue, after onSubmit has run.
Valid, onSubmit returns a promiseA promise that settles when yours does.
Any async rule is involvedA 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

KeyTypeNotes
valuesobjectInitial 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.
onSubmitcallableCalled as onSubmit(values, extra) only when validation passes. Return a promise to keep submitting true.

Reactive state

MemberTypeReads
.valuesobjectCurrent field values. Two‑way bindable per field: value: form.values.email.
.errorsobjectMessage per failing field; a field that passes has no entry.
.touchedobjecttrue per field the user has left, or that a submit touched.
.dirtybooleanCurrent values differ from the baseline. Flips back to false when they match again.
.validbooleanResult of the last validate() / handleSubmit(). Starts true.
.submittingbooleanA submit is in flight. Stays true until an async onSubmit settles.
.validatingbooleanAt least one async validator is pending.

Methods

CallReturnsEffect
.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 itValidate one field and write its error.
.validate()boolean, or a promise of itValidate 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 promiseTouch 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.

WrapperReach 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 ofUseBecause
Input for filtering a listSearchBarMagnifier icon and keyboard hint are already there.
Input({ type: "password" })PasswordInputShow/hide toggle, plus an optional 4‑step strength meter.
Input({ type: "number" })NumberInput+/− buttons that respect min and max.
Checkbox for a settingSwitchA setting reads as on/off, not as a choice being ticked.
Select over a long listComboboxType‑to‑filter for countries, currencies, users.
Several checkboxes for tagsMultiSelect · TagInputRemovable chips bound to one array.
Input for a codePinInputPer‑digit entry for 2FA and SMS codes (length: 6 for OTP).
Input for a phone or postcodeMaskedInputFormats against a mask (9 digit, A letter, * any).
Two date inputsDateRangePickerOne shared range, with from and to bound together.
Radio buttons for a view modeToggleGroup · SegmentedControlMutually 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

Next