Forms flow
A form is a typed draft of a record, the baseline it came from, and one small piece of state per field path. The rules come from the request schema of the operation; the DOM only shows the model.
This page is the inside view. For using forms, see Forms.
From the schema to the controls
The pieces, in the order the data meets them:
The model is the single source of truth. The DOM is a view of it: values go out through effects, changes come back through delegated events. In motion, a dot follows the road in order while the code that causes the step is lit. The schema becomes the bodySchema and, with the record, makes the model; the model's values go out through the binder to the controls on the screen. Then a change a person makes comes back as set(path, raw), and the new value and error go out again. Use the slider, or press a number, to look at one step.
bodySchema.xoong syncconverts the JSON request body of an operation into a compact node tree and stores it inoperations.json. A node has a typet(string,integer,number,boolean,object,array,union,any) and the rules that OpenAPI states:format,enum, length, range,pattern,nullable,readOnly,default,title,description.allOfis merged;oneOf: [X, null]is a nullable X; aoneOfof objects becomes aunionwhen a discriminator or a property with one fixed value per member tells them apart. Otherwise the node isany, which is accepted as it is and not drawn by<xo-form>, andxoong doctorwarns (XO161). A multipart body has nobodySchema:xo-submitsendsFormData.- The record. An edit form (the operation has a path parameter, or
xo-bodynames a record) starts from the record in its scope, read from the store. A create form starts from the schema's defaults. - The model copies the record twice: the baseline (what was loaded) and the draft (what the person edits).
- The binder connects the model to the real
<form>.
{
"t": "object",
"required": ["title"],
"props": {
"title": { "t": "object", "props": { "en": { "t": "string", "minLength": 1 }, "vi": { "t": "string" } } },
"publishedAt": { "t": "string", "format": "date-time", "nullable": true },
"blocks": { "t": "array", "items": { "t": "union", "key": "type", "map": {
"text": { "t": "object", "props": { "type": { "t": "string", "enum": ["text"] }, "body": { "t": "string" } } },
"image": { "t": "object", "props": { "type": { "t": "string", "enum": ["image"] }, "src": { "t": "string", "format": "uri" } } }
} } }
}
}The form model
createForm({ schema, value, rules }) has no DOM and no framework: it is pure signals, so it is tested in plain Node.
import { createForm } from '@xoong/forms';
// No DOM needed: the model runs in plain Node, which is how it is tested.
const form = createForm({ schema: op.bodySchema, value: record });
form.set('title.en', 'Hello'); // a control's raw value, coerced to the schema's type
form.field('title.vi').error; // reactive: read it inside an effect or a template
form.dirty; // normalise(draft) differs from normalise(baseline)
await form.submit((body) => client.mutate('updateArticle', { id }, body));- Per-path state.
form.field("title.en")givesvalue,error,touched,dirtyandfilledfor that path. Each is a signal created the first time it is asked for and updated only when its own value changes, so an edit wakes the readers of that field and its parents, not the other few hundred. A path is writtena.boritems[2].x;items.2.xis the same. - Typed values. A control gives a string.
set(path, raw)coerces it with the node: numbers become numbers, a checkbox a boolean, empty text becomesnullfor a nullable field and otherwise "nothing" (the key is left out). A text that is not a number stays text and fails validation. - Errors, in order of priority. The server's field errors, then async rules, then synchronous custom rules, then the schema's own rules. For one path the later one wins, so a schema error is never hidden by a custom rule. The schema checks required, type, minimum and maximum (also exclusive),
multipleOf, lengths,pattern,format(email, uri, uuid, date, date-time),enum, item counts, uniqueness, and that a union has a variant. - Rules are plain functions keyed by path (
"items[].title"covers every item;"*"is the whole record and returns errors by path). Synchronous rules run on every change. An async rule (one that returns a promise) marks the formpendingand is re-run only for the path that changed; an answer for an older value is ignored. A rule that throws becomes an error, not a crash. - What is sent.
form.body()reduces the draft to the request schema: only properties of the schema (ids and timestamps of the record never go out; read-only ones are skipped), empty values followemptyAs(omitby default, ornull),pruneEmptydrops empty members of an object, and a node of typeanyis kept as it is. A nullable property that the schema lists as required is sent asnullwhen empty, because the key must be present.serializereplaces all of this. - Dirty uses the same reduction on both sides, and treats
nulland absent as equal. Typing a letter and deleting it is pristine again. - Arrays.
append,removeAtandmoveItemchange the draft; touched marks follow their item, and the errors of that array are recomputed for the new positions.onArrayChangereports it; the binder turns that into thexo-array-changeevent.
<xo-view get="getArticle" p-id=":id">
<form xo-submit="updateArticle">
<label>Slug <input name="slug"></label>
<label>Title <input name="title.en"></label>
<small xo-if="$form.errors['title.en']">{$form.errors['title.en']}</small>
<p xo-if="$form.dirty">Unsaved changes</p>
<button>Save</button>
</form>
</xo-view>const form = document.querySelector('form'); // after its xo-view has started
form.rules = {
slug: async (value, { record }) => ((await isTaken(value, record.id)) ? 'Already used' : ''),
'*': (record) => (record.start > record.end ? { end: 'Must be after the start' } : {}),
};
form.serialize = (draft, body) => body; // the escape hatch for the payloadThe DOM binder
Every <form xo-submit> whose operation has a bodySchema gets a binder as soon as its view starts, once @xoong/forms is imported (importing it registers the binder with xo-submit). It is attached at once, not at the first click, so fields are filled before anyone touches the form.
novalidate. The browser's own validation is switched off. Validity lives in the model, so an invalid control that is hidden (another tab, a collapsed block) can never block the submit with "not focusable".- DOM to model. One delegated
inputandchangelistener on the form, plusfocusoutfor "touched". A form of 800 fields has three listeners. A control is found by itsnamepath. The model is written only by these user events: settingel.valuefrom code does not touch it, which also means an untouched date-time keeps its exact original string (an edit converts local time to UTC ISO). - Model to DOM. For each control, one effect writes its value and one shows its error once the field is touched (a
<small class="xo-error">,aria-invalid,aria-describedby, andsetCustomValidity). The effect skips the control the change came from, so it never rewrites what the person is typing, and it reads the control untracked, so what the control does inside (its own value, its validity) cannot subscribe it. - Completing attributes. From the node, the binder fills in only what you left out:
type,step,min,max,minlength,maxlength,pattern,required,readonly, the options of an enum<select>. An attribute you wrote always wins. - Controls that come and go. A
MutationObserverbinds controls added later (rows ofxo-each, a revealed tab) and re-binds a control whosenamechanges (a reordered row). A custom element used before its class is defined is bound when the browser upgrades it. - Custom controls. Any form-associated element with a
name(everyXoControl) is the field of that path. It receivesschema(its node),hints(every hint of the field, unknown keys included) andxoForm(the model, to read other paths). It writes withuserInput(value), which firesinput. - After a save the model's baseline becomes the saved record (
markPristine), the controls are rewritten from it, and custom controls are told, so their owndirtyagrees with the form's. A create form resets to the defaults instead, unless it hasxo-keep. - The record underneath. The binder watches the record. When it changes (a live update), a pristine form follows it; a dirty form keeps its draft and
$form.stalebecomes true.
The binder announces itself with events that bubble from the form: xo-form-ready (form.model exists from then), xo-dirty ({ dirty }: the router listens to this, see Router), xo-array-change and xo-reveal. The view around the form fires xo-invalid. In markup, $form is the model: $form.dirty, $form.valid, $form.submitting, $form.errors["title.vi"], $form.touched[...], $form.filled[...]. A tab can show an error dot from the model, even for fields that are not on the visible tab.
The submit pipeline
Nothing leaves the page until the model says the draft is valid.
xo-submitfirst asksxo-confirmif there is one, then callsengine.submit. The model touches every field (so every error shows), validates, and awaits the async rules.- Invalid. Nothing is sent. The binder finds the first invalid path in the order controls appear on the page and fires a bubbling, cancelable
xo-reveal{ path }. If a listener callspreventDefault()(it switched a tab, selected a block), the binder waits 30 milliseconds for the render. Then the control is focused (a custom control focuses itsfocusTarget). Last, the view firesxo-invalidwith the errors. - Valid.
client.mutatesends the normalised body, with the optimistic patch if the button asked for one. Then the model marks itself pristine or resets, as above. - The server says no. A
XoongErrorcarriesfields, keyed by dotted path. The client builds them from a pointer (/title/enbecomestitle.en), from{ error: { fields } }, or from FastAPI'sloc. The model puts them on the same paths as its own errors, so they appear next to the field. A field's server error clears as soon as that field is edited. A failure with no field messages shows itsdetailas arole="alert"summary under the form.
The generated form
<xo-form op="updateUser"> is only a markup generator. It writes the <form xo-submit> you would have written from the operation's bodySchema, and from then on it is an ordinary form with the same binder. There is no second code path and no second validation.
<!-- you write -->
<xo-form op="updateUser" submit="Save"></xo-form>
<!-- the template writes (then the binder takes over, as for any xo-submit form) -->
<form xo-submit="updateUser" class="xo-form">
<label class="xo-field"><span>Name</span><input name="name"></label>
<label class="xo-field"><span>Email</span><input name="email"></label>
<button class="xo-submit">Save</button>
</form>{
"fields": {
"Article.slug": { "hint": "Used in the address", "order": 1 },
"Article.body": { "widget": "xo-richtext", "label": "Body", "order": 2 },
"Article.internalNote": { "hidden": true }
}
}- A string, number or integer is a labelled input (the label is the schema's
title, else the humanised name). Anenumis a<select>, a boolean a checkbox, andformatchooses the input type. - An object is a
<fieldset>with a legend. An array is a fieldset of rows overxo-each, each with move up, move down and remove buttons (and a drag handle), and an Add button. A union gets one "Add variant" button per variant, and each row shows the fields of its variant (xo-if="type=image"). - Hints change the drawing:
label,hint,placeholder,order,group(a fieldset with a legend),hidden(kept in the model and sent, not drawn),readOnly(not drawn, not sent) andwidget. A widget is a built-in name (textarea,password,url,email,color,date,datetime,select) or the tag of a custom element that extendsXoControl(any name with a hyphen). They come from the OpenAPItitleanddescription, fromx-xoong-widgeton a node, or from thefieldsmap ofxoong.hints.json, keyed by schema and property, for documents you cannot edit. Any other hint passes through to the widget inhints. - Without a
bodySchema(a multipart body) the generator draws the flatbodyFields, and files become file inputs.
Where to look in the code
packages/cli/src/body-schema.js: request schema to node treepackages/forms/src/schema.js: paths,coerce,defaults,validate, the message cataloguepackages/forms/src/form-model.js:createForm,normalize, rules, arrays,submitpackages/forms/src/form-dom.js: the binder,attachpackages/data/src/view.js:bindAction(wherexo-submitmeets the form engine),$form, the array buttonspackages/ui/src/form.js:<xo-form>, the markup generatordocs/decisions/0009-forms-from-the-api-schema.md: the reasons behind this design