Forms Reference
The details of the form behavior. Start with the recipe on Forms: it covers validation, the submit, pending state, server errors and field arrays.
The slice
Section titled “The slice”state.form holds:
| Field | |
|---|---|
values, initial | The current values, and the ones the form started with (or was last saved or reset with) |
errors | Every current schema error by field name, shown or not |
touched, server, remote, pending | Blurred fields; server errors; check results; checks running |
submitting, submitted, submitCount, queued, validating, validated | Submit state: the request sent and not answered yet; form.DONE arrived (or a submit without a request was dispatched); attempts; a submit waits for a check or an async schema; an async schema runs; the schema has answered since the start or the last reset |
fields, valid, dirty, error | Calculated: per-field view data; no errors as of the schema’s last answer (false before the first); values differ from initial; the form-level message |
The options are on Forms.
The actions
Section titled “The actions”Named after the uses key (form.CHANGE for uses = { form: … }):
| Action | Data |
|---|---|
form.CHANGE | { name, value }: from the form element’s input events (field types; a checkbox gives checked, and its value as item) |
form.SET | { values }: several fields at once, by field name ({ name: 'Dana', 'addresses.7.city': 'Hilo' }), as if the user had typed them: one validation, the fields count as changed, touched as for form.CHANGE (so errors show by the show rule), their server errors and check results cleared. Fields not named keep their values; a name that isn’t a field is skipped (SYG230 in development). For a fill from elsewhere: an AI extraction, a saved draft, an address lookup |
form.BLUR | The field name, from focusout |
form.SUBMIT | From the form element’s submit (default prevented) |
form.ADD / form.REMOVE | { field, value } / { field, id }: field array rows |
form.ERRORS | Server errors: an error reply, a map or a list of issues |
form.DONE | The submit’s request was saved: initial becomes values, submitted turns on (a submit without a request needs none) |
form.RESET | Back to initial, or to the values given |
form.VALIDATE | Internal: the first validation when the host starts; with resetOnShow, the start over (also each time the form element appears again) |
As for any behavior, a host model entry with the same name runs after the form’s: 'form.DONE': (state) => ({ ...state, done: true }) shows a confirmation. Trigger an action from elsewhere with an intent action of that name, or t.simulateAction('form.RESET') in a test.
Any Standard Schema
Section titled “Any Standard Schema”form() takes any object with ~standard.validate: zod (3.24 and later), valibot (1.0 and later), arktype, or one you write. A schema’s issue paths become field names, and an issue without a path is a form-level message (state.form.error, shown after a submit):
const EMAIL = /^\S+@\S+\.\S+$/
export const signupSchema = { '~standard': { version: 1, vendor: 'my-app', validate(values) { const issues = [] if (!values.name.trim()) issues.push({ message: 'Enter your name', path: ['name'] }) if (!EMAIL.test(values.email)) issues.push({ message: 'Enter a valid email address', path: ['email'] }) return issues.length ? { issues } : { value: { ...values, email: values.email.trim().toLowerCase() } } }, },}An async schema (valibot’s pipeAsync, zod’s async refinements) works too: state.form.validating is true while it runs (also at the start, until its first answer) and a submit waits for it. state.form.valid is false until the first answer; after that it keeps the last answer’s validity while the schema re-validates, so <button disabled={!state.form.valid}> doesn’t flicker on every keystroke (show progress with validating). The schema first runs when the component starts, not when form() is called. Something that isn’t a Standard Schema is SYG231.
Async checks
Section titled “Async checks”Some checks need the server: is this email address taken? check names a field and the request that checks it. The request goes to the HTTP driver (makeFetchDriver) with a reply action the form handles, and latest: true, so a check for a newer value replaces the older one.
In this demo, a stand-in server answers both requests: ada@example.com is already registered, and so is each address that signs up.
const registered = ['ada@example.com']
export default { 'GET /api/email-available': ({ query }) => ({ json: { available: !registered.includes(query.email) } }), 'POST /api/signup': ({ json }) => { if (registered.includes(json.email)) return { status: 422, json: { errors: { email: 'Already registered' } } } registered.push(json.email) return { status: 201, json: { id: registered.length } } },}The schema checks the address’s form, in ./schema.js:
import { z } from 'zod'
export const signupSchema = z.object({ email: z.string().trim().toLowerCase().email('Enter a valid email address'),})The form checks the address with the server when the field loses focus:
import { form } from 'sygnal'import { signupSchema } from './schema.js'
function Signup({ state, uid }) { const f = state.form.fields return ( <form className="signup" noValidate> <label for={uid('email')}>Email</label> <input id={uid('email')} name="email" type="email" value={f.email.value} aria-invalid={f.email.invalid} aria-describedby={uid('email-error')} /> <p id={uid('email-error')}>{f.email.pending ? 'Checking…' : f.email.error}</p> <button type="submit" disabled={state.form.submitting}>Sign up</button> <p role="status">{state.form.submitted ? 'Signed up.' : ''}</p> </form> )}
Signup.uses = { form: form(signupSchema, { values: { email: '' }, submit: 'SIGN_UP', check: { email: { request: (email) => ({ url: '/api/email-available', query: { email } }), error: (body) => !body.available && 'This email is already registered', }, }, }),}
Signup.model = { SIGN_UP: { HTTP: (state, values) => ({ url: '/api/signup', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },}The message line keeps its height, so the button doesn’t move when Checking… appears (why):
.signup p { min-height: 1.5em; }- A field is checked when it loses focus, once per value, and only when its schema error is clear and it isn’t empty.
error(body)turns the reply into a message, or a falsy value when the value is fine. - A submit while a check runs (or before a field was ever checked) waits for it:
state.form.queuedistrue, and the submit goes on once every check passed, or stops and focuses the field whose check failed. Checks for several fields run one after the other. - Editing the field drops its check: a late reply for the old value is ignored. Editing any field cancels a queued submit.
- A check that fails (network error, 500) doesn’t block the form: the server validates again on submit.
A test answers the check like any request:
import { renderComponent } from 'sygnal'import { it, expect } from 'vitest'import Signup from './Signup.jsx'
it('validates, checks the email, and posts the cleaned values', async () => { const t = renderComponent(Signup, { strict: true }) await t.ready() t.simulateEvent('[name="email"]', 'input', { value: 'nope' }) t.simulateEvent('[name="email"]', 'focusout') await t.settle() expect(t.state.form.fields.email.error).toBe('Enter a valid email address')
t.simulateEvent('[name="email"]', 'input', { value: 'Ada@Example.com' }) t.simulateEvent('[name="email"]', 'focusout') await t.settle() expect(t.requests('HTTP').at(-1)).toMatchObject({ url: '/api/email-available' }) await t.respond('HTTP', { available: true })
t.simulateEvent('.signup', 'submit') await t.settle() expect(t.requests('HTTP').at(-1)).toMatchObject({ url: '/api/signup', json: { email: 'ada@example.com' } }) await t.fail('HTTP', { status: 422, body: { errors: { email: 'Already registered' } } }) expect(t.state.form.fields.email.error).toBe('Already registered') t.expectNoDiagnostics()})Two forms in one component
Section titled “Two forms in one component”Each form use listens on its form selector inside the component, so two uses left at the default 'form' would both hear every form element: typing in one changes the other, and each submit is handled twice. Give each form element a class and pass it (SYG237 warns otherwise); the first invalid field focused on submit is then also searched in that form only:
import { form } from 'sygnal'import { loginSchema, newsSchema } from './schemas.js'
function Account({ state }) { const login = state.login.fields, news = state.news.fields return ( <div> <form className="login" noValidate> <input name="email" aria-label="Email" value={login.email.value} /> <button type="submit">Sign in</button> </form> <form className="news" noValidate> <input name="email" aria-label="Newsletter email" value={news.email.value} /> <button type="submit">Subscribe</button> </form> </div> )}
Account.uses = { login: form(loginSchema, { values: { email: '' }, submit: 'SIGN_IN', form: '.login' }), news: form(newsSchema, { values: { email: '' }, submit: 'SUBSCRIBE', form: '.news' }),}
Account.model = { SIGN_IN: { HTTP: (state, values) => ({ url: '/api/sign-in', method: 'POST', json: values, ok: 'login.DONE', error: 'login.ERRORS' }) }, SUBSCRIBE: { HTTP: (state, values) => ({ url: '/api/newsletter', method: 'POST', json: values, ok: 'news.DONE', error: 'news.ERRORS' }) },}Rows as components
Section titled “Rows as components”The recipe renders field array rows inline, which needs no child component. When a row is big enough to be a component of its own, render the rows with a Collection, pass fields down, and send the remove up with PARENT:
import { Collection, form } from 'sygnal'import { signupSchema } from './schema.js'
function Address({ state, fields, uid }) { const city = fields[`addresses.${state.id}.city`] return ( <div className="address"> <label for={uid('city')}>City</label> <input id={uid('city')} name={city.name} value={city.value} aria-invalid={city.invalid} aria-describedby={uid('city-error')} /> <p id={uid('city-error')}>{city.error}</p> <button type="button" className="remove">Remove address</button> </div> )}
Address.intent = ({ DOM }) => ({ REMOVE: DOM.click('.remove') })Address.model = { REMOVE: { PARENT: (state) => ({ field: 'addresses', id: state.id }) } }
function Signup({ state, uid }) { const f = state.form.fields return ( <form className="signup" noValidate> <label for={uid('email')}>Email</label> <input id={uid('email')} name="email" type="email" value={f.email.value} aria-invalid={f.email.invalid} aria-describedby={uid('email-error')} /> <p id={uid('email-error')}>{f.email.error}</p> <fieldset aria-describedby={uid('addresses-error')}> <legend>Addresses</legend> <Collection of={Address} from={{ get: (s) => s.form.values.addresses }} fields={f} /> <p id={uid('addresses-error')}>{f.addresses.error}</p> <button type="button" className="add-address">Add address</button> </fieldset> <button type="submit" disabled={state.form.submitting}>Sign up</button> </form> )}
Signup.uses = { form: form(signupSchema, { values: { email: '', addresses: [{ id: 1, city: '' }] }, submit: 'SIGN_UP' }),}
Signup.intent = ({ DOM, CHILD }) => ({ 'form.ADD': DOM.click('.add-address').mapTo({ field: 'addresses', value: { city: '' } }), 'form.REMOVE': CHILD.select(Address),})
Signup.model = { SIGN_UP: { HTTP: (state, values) => ({ url: '/api/signup', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },}- The Collection reads the rows through a read-only lens (
from={{ get }}): rows don’t write their own state, the form does, because the row’s inputs are inside the form element. - Fields of child components and Collection items inside the form element count as the form’s fields.
- A failed submit focuses the first invalid field even inside a row.
Saving and resetting
Section titled “Saving and resetting”form.DONE marks the submit saved: submitting turns off, submitted on, and the saved values become initial (so dirty is false again).
Whether a valid submit waits for form.DONE depends on the host’s entry for the submit action, checked when the host starts:
- With the
httpsink (SIGN_UP: { HTTP: … }, or the sink thehttpoption names, such ashttp: 'API'):submittingistrueuntilform.DONEorform.ERRORS, and a submit meanwhile is dropped (SYG232). Even when the entry returnsABORTthis time, the form waits. - Anything else (
STATE,PARENT,EVENTS,EFFECT,ROUTER, a reducer function): no request, so the submit is done at once, as ifform.DONEhad arrived (no hostform.DONEentry runs). AnEFFECTthat callsnext('form.DONE')itself still works; the secondDONEchanges nothing. Work that answers later without thehttpsink (a promise in anEFFECT) gets no pending state: send it through a driver as a request instead. - A host that unmounts keeps its slice in the parent’s state when it has a
stateprop. When it mounts again, the form clearssubmitting,queuedand running checks (their replies went to the old host); the values, errors andsubmittedstay.form.RESETgoes back toinitial, or to the values it is given (t.simulateAction('form.RESET', values), or an intent action'form.RESET': DOM.click('.reset')); it clears errors, touched fields and the submit count. - Starting over when shown (
resetOnShow: true): the form goes back to its start values, as a new form (initialtoo; touched fields, errors, server errors, checks and the submit state cleared), when its host starts (mounted again, a Switchable page made again for anotherinstance) and each time its form element appears in the host’s DOM after being absent (a Switchable page shown again: hidden pages stay alive and keep their state; a form rendered conditionally). Re-renders while the form is shown keep what the user typed. With the mock DOM (and in SSR) only the host’s start counts. Avaluesfunction gets the host’s whole state and is called at each start over; withoutresetOnShow, once, when the host starts and the slice is still untouched. Before its first call (the first render of a page that reads its parent’s state) the fields come from calling it with a blank state, every value read from it''.
Without the behavior: the helpers
Section titled “Without the behavior: the helpers”The behavior covers the common form. When a form needs its own state layout or flow, write the actions yourself and use the functions the behavior is built on; each is a pure function for a reducer:
| Helper | |
|---|---|
checkForm(schema, values) | { errors, value }: errors by field name and the schema’s output; a Promise for an async schema |
formErrors(schema, values) | Only the errors ({} when valid) |
setField(values, name, value), getField(values, name), hasField(values, name) | Immutable set and get by field name (rows by id); whether the field exists (also with an undefined value) |
fieldName(values, path), fieldNames(values) | An issue path as a field name; every field name of values |
replyErrors(reply, values?) | Server errors (a reply, a map or a list of issues) as field errors |
focusInvalid(errors, within?) | An ELEMENT command that focuses the first field with an error, children included; with within (the form element’s selector), only a field inside that element; ABORT when there is none |
import { formErrors, setField, focusInvalid, ABORT } from 'sygnal'import { profileSchema } from './schema.js'
function Profile({ state }) { return ( <form className="profile" noValidate> <label>Display name <input name="name" value={state.values.name} /></label> <p>{state.submitted ? state.errors.name : ''}</p> <button type="submit">Save</button> </form> )}
Profile.initialState = { values: { name: '' }, errors: formErrors(profileSchema, { name: '' }), submitted: false }
Profile.intent = ({ DOM }) => ({ CHANGE: DOM.select('.profile').events('input').map((e) => ({ name: e.target.name, value: e.target.value })), SUBMIT: DOM.select('.profile').events('submit', { preventDefault: true }),})
Profile.model = { CHANGE: (state, { name, value }) => { const values = setField(state.values, name, value) return { ...state, values, errors: formErrors(profileSchema, values) } }, SUBMIT: { STATE: (state) => ({ ...state, submitted: true }), ELEMENT: (state) => focusInvalid(state.errors, '.profile'), HTTP: (state) => (Object.keys(state.errors).length ? ABORT : { url: '/api/profile', method: 'PUT', json: state.values }), },}Offer the form to the browser’s agent
Section titled “Offer the form to the browser’s agent”With tool: formTool({ … }), the form becomes a declarative WebMCP tool: the browser’s AI agent sees it, fills its fields and submits it.
import { form } from 'sygnal'import { formTool } from 'sygnal/ai'
Signup.uses = { form: form(signupSchema, { values: { email: '', plan: '' }, submit: 'SIGN_UP', tool: formTool({ name: 'sign_up', description: 'Create an account' }), }),}- The form writes the attributes the browser reads:
toolnameandtooldescriptionon the<form>,toolautosubmitwithautosubmit: true, and atoolparamdescriptionon each named field, from its<label>(wrapping orfor=) and otherwise itsaria-label. The view stays as it is. To write one yourself, use theattrs-prefix (attrs-toolparamdescription="…"), which wins: a baretoolnamein JSX sets a DOM property the browser ignores (SYG153). - An agent’s submit is an ordinary
form.SUBMIT: the same schema, checks andsubmitaction. The agent’s call resolves after the outcome is rendered, with{ ok: true, values }(the schema’s output, whatsubmitgot) or{ ok: false, errors }(the field errors by name;''is form-level). A submit sent as a request answers after its reply:form.DONEgivesok: true,form.ERRORSthe server errors. autosubmitdefaults tofalse: the agent’s call fills the fields and stays pending until the user submits the form, then gets the same answer. Setautosubmit: trueonly for forms that are safe to submit without the user looking.- A user’s own submit is unchanged.
formToolcomes fromsygnal/aiso that an app usingformwithout a tool doesn’t carry the tool code. A plain object astooldoes nothing but report SYG245 in development.
Diagnostics
Section titled “Diagnostics”With the dev checks on (the Vite plugin in dev, renderComponent in tests): SYG230 a field inside the form whose name isn’t in values, SYG231 a schema that isn’t a Standard Schema, SYG232 a submit dropped while one is in progress (info), SYG233 a value the schema strips from its output, SYG234 a submit action the model doesn’t have, SYG235 a check for an unknown field, or a check request that sets ok/error/latest, SYG236 array rows without an id, and SYG237 two forms in one component on the same selector. sygnal-check knows form: an option typo is SYG127, and fields inside the form element are not reported as uncontrolled (SYG111).
processForm()
Section titled “processForm()”For a form without validation, processForm() reads every named field of a form element on its events:
import { processForm } from 'sygnal'
function ContactForm({ state }) { return ( <form className="contact-form"> <input name="name" aria-label="Name" value={state.name} /> <input name="email" aria-label="Email" value={state.email} /> <textarea name="message" aria-label="Message">{state.message}</textarea> <button type="submit">Send</button> </form> )}
ContactForm.initialState = { name: '', email: '', message: '' }
ContactForm.intent = ({ DOM }) => ({ // Listen only to submit events SUBMIT: processForm(DOM.select('.contact-form'), { events: 'submit' }),
// Listen to all input changes (default: both 'input' and 'submit') FIELD_CHANGE: processForm(DOM.select('.contact-form'))})
ContactForm.model = { FIELD_CHANGE: (state, data) => ({ ...state, name: data.name, email: data.email, message: data.message }), SUBMIT: { STATE: (state) => ({ ...state, submitted: true }), LOG: (state, data) => data // { name: '...', email: '...', message: '...', eventType: 'submit', event } }}processForm() Options
Section titled “processForm() Options”processForm(DOM.select('.my-form'), { events: 'submit', preventDefault: true })| Option | Type | Default | Description |
|---|---|---|---|
events | String or Array | ['input', 'submit'] | Which form events to listen for |
preventDefault | Boolean | true | Whether to call preventDefault() on events |
Return Value
Section titled “Return Value”The stream emits an object with:
- All form field values keyed by their
nameattribute event— The raw DOM eventeventType— The event type string (e.g.,'submit','input')