Form Controllers
When a form declares @form, Udodi creates a reactive form controller under ud.forms.<name>.
The controller is the public API for:
- form validation and interaction state
- field errors
- field values and state
- programmatic field updates
- resetting forms and fields
- server-side errors
- submit state
- validation mode
Accessing the Controller
A form registered as:
<form @form="login">
...
</form>creates a controller at:
ud.forms.loginThe controller is available through the component's public ud context.
In templates
Controller state can be consumed directly when it is already a reactive property:
<span @text="ud.forms.login.errors.email"></span>
<button @attr="disabled=ud.forms.login.submitting=>'disabled'">
Sign in
</button>In methods
methods: {
clear() {
this.ud.forms.login.reset();
},
}In computed values
Computed values are the preferred way to derive reusable UI state from controller or field state:
computed: {
canSubmit() {
const form = this.ud.forms.login;
if (form.valid && !form.submitting) {
return undefined; // this clear the "disabled" attribute
}
return "disabled"; // disable the submit button
},
},Then use the computed value in the template:
<button @attr="disabled=canSubmit">
Sign in
</button>For field-specific derived state, use a computed value rather than calling getField() directly from a directive expression which is invalid:
computed: {
emailDirty() {
return this.ud.forms.login.getField("email")?.dirty ?? false;
},
},<span @show="emailDirty">
Changed
</span>This keeps field-state lookup and derived logic in the component's reactive layer.
In submit handlers
A submit handler receives the same controller instance through its submit context:
methods: {
async login({ formData, controller }) {
// controller === this.ud.forms.login
await api.login(Object.fromEntries(formData));
controller.reset();
},
}Controller Shape
A controller exposes reactive status properties, a reactive error map, and imperative field/form methods:
{
// Reactive status
valid: true,
validating: false,
dirty: false,
touched: false,
submitting: false,
submitted: false,
// Read-only validation strategy
validationMode: "sequential",
// Reactive field errors
errors: {
email: "",
password: "",
},
// Form operations
reset(options?),
// Field operations
resetField(name, options?),
getField(name),
getValue(name),
setValue(name, value),
// Error operations
setError(name, message),
resetError(name),
}The exact initial contents of errors depend on which named fields have registered @validate.
validationMode is a read-only property reflecting the mode declared by @form:
<form @form="login parallel">ud.forms.login.validationMode;
// "parallel"The available modes are:
"sequential"— the default"parallel"
See Sequential and Parallel Validation.
Status Properties
The controller exposes aggregate form state:
| Property | Type | Meaning |
|---|---|---|
valid | boolean | true only when every entry in errors is an empty string |
validating | boolean | true while one or more field validations are in progress |
dirty | boolean | true when at least one registered field is dirty |
touched | boolean | true when at least one registered field has been focused |
submitting | boolean | true while the @submit pipeline is active |
submitted | boolean | true after successful validation and handler completion, unless reset() was called |
validationMode | "sequential" | "parallel" | Form-level strategy used for full-form validation |
valid
valid is derived entirely from the controller's errors object.
A form is valid when every registered error is an empty string:
ud.forms.login.validSetting an error makes the form invalid:
ud.forms.login.setError(
"email",
"This email is already registered"
);Clearing it restores validity when no other errors remain:
ud.forms.login.resetError("email");validating
validating is true while one or more registered fields are being validated.
This includes asynchronous validators:
<p @show="ud.forms.login.validating">
Checking...
</p>The form remains validating until all currently active field validation cycles have settled.
dirty
dirty is an aggregate of registered field state.
It becomes true when any registered field's current value differs from its initialValue.
<p @show="ud.forms.login.dirty">
You have unsaved changes.
</p>See Working with Fields for field-level dirty semantics.
touched
touched is an aggregate of registered field state.
It becomes true when any registered field has received focus.
<p @show="ud.forms.login.touched">
Form has been interacted with.
</p>submitting
submitting tracks the @submit lifecycle:
<button
type="submit"
@attr="disabled=ud.forms.login.submitting=>'disabled'"
>
Save
</button>It becomes true when submission begins and remains true while validation and the submit handler are active.
See Form Submission.
submitted
submitted becomes true after:
- form validation succeeds, and
- the submit handler completes successfully.
If the submit handler calls:
controller.reset();the reset state is preserved and the submission lifecycle does not force submitted back to true.
This allows a successful submission to leave the form clean without producing an unwanted submitted-state transition.
validationMode
validationMode exposes the form's full-validation strategy:
ud.forms.login.validationModeIt is read-only.
The value comes from @form:
<form @form="login">
<!-- sequential -->
</form>
<form @form="signup sequential">
<!-- sequential -->
</form>
<form @form="checkout parallel">
<!-- parallel -->
</form>The mode affects how different fields are coordinated during full-form validation. It does not change the order of validators declared on a single field.
See Sequential and Parallel Validation.
errors
errors is the controller's reactive map of field names to validation messages:
ud.forms.login.errors.email
// "" or "This field is required"
ud.forms.login.errors.password
// "" or "At least 8 characters"A named field using @validate creates its error entry when it registers:
<input
name="email"
@validate="required email"
/>The corresponding controller state becomes:
ud.forms.login.errors.emailwith an initial value of:
""Error semantics
""means the field currently has no error.- A non-empty string represents the field's current error.
- Validators update the error automatically.
setError()allows application code to set an error manually.resetError()clears an error without running validators.- Removing the last registered field for a name removes that error entry.
Because valid is derived from errors, changing an error also updates form validity.
Displaying errors
Direct controller properties can be consumed directly:
<span @text="ud.forms.login.errors.email"></span>For derived error state or more complex presentation logic, use a computed value.
Methods
reset(options?)
Resets the entire form.
controller.reset();By default, the native form is also reset:
controller.reset({
clearForm: true,
});To reset controller state without calling the native form.reset():
controller.reset({
clearForm: false,
});Options
| Option | Default | Effect |
|---|---|---|
clearForm | true | Calls the native form.reset() when available |
Controller state after reset
reset():
- sets
validtotrue - sets
dirtytofalse - sets
touchedtofalse - sets
validatingtofalse - sets
submittingtofalse - sets
submittedtofalse - clears every registered error
- clears field
touchedstate - clears field
dirtystate - clears field
validatingstate - refreshes each field's
initialValuefrom its current DOM value
Example:
controller.reset();After the reset:
controller.valid === true;
controller.dirty === false;
controller.touched === false;
controller.submitting === false;
controller.submitted === false;
controller.validating === false;Reset and submission
reset() also records that the form was explicitly reset during the current submission lifecycle.
Therefore, if a submit handler does:
async save({ controller }) {
await saveData();
controller.reset();
}the submit lifecycle does not subsequently force:
controller.submitted = true;This is useful when a successful submission should immediately leave the form in its clean initial state.
See Form Submission.
resetField(name, options?)
Resets one field or all controls belonging to a field group.
controller.resetField("email");For grouped controls such as radios or checkboxes sharing a name, every registered control in the group is reset.
controller.resetField("plan");Options
controller.resetField("email", {
clearField: false,
});| Option | Default | Effect |
|---|---|---|
clearField | true | Clears the control. If false, keeps the current DOM value and uses it as the new initialValue |
With the default:
controller.resetField("email");the control is cleared and its interaction state is reset.
With:
controller.resetField("email", {
clearField: false,
});the current DOM value is retained and becomes the field's new baseline.
Effects
resetField():
- clears
touched - clears
dirty - clears
validating - clears the field's error
- recalculates form-level
touched - recalculates form-level
dirty
Return value
Returns true when the field exists.
Returns false when no registered field matches the supplied name.
getField(name)
Returns the normalized state of a registered field.
const field = controller.getField("email");The returned field state has the following shape:
{
element,
name,
type,
value,
touched,
dirty,
validating,
initialValue,
}Result
| Result | Meaning |
|---|---|
| Object | Exactly one registered control has the name |
| Array | Multiple registered controls share the name |
undefined | No registered field has the name |
For example:
const email = controller.getField("email");
console.log(email.value);
console.log(email.dirty);
console.log(email.touched);Field groups
For a radio or checkbox group:
<input
type="radio"
name="plan"
value="free"
@validate="required"
/>
<input
type="radio"
name="plan"
value="pro"
@validate="required"
/>getField("plan") returns an array:
[
{
element,
name: "plan",
type: "radio",
value: "free",
touched,
dirty,
validating,
initialValue,
},
{
element,
name: "plan",
type: "radio",
value: "pro",
touched,
dirty,
validating,
initialValue,
},
]Template usage
getField() is an imperative lookup API.
When field state needs to participate in reactive template rendering, prefer exposing the derived state through a computed value:
computed: {
emailDirty() {
return this.ud.forms.login.getField("email")?.dirty ?? false;
},
},Then:
<span @show="emailDirty">
Changed
</span>Trying to use getField() in a directive expression result to invalid directive DSL.
getValue(name)
Returns the current value of a registered field.
const email = controller.getValue("email");If no field with that name exists, undefined is returned.
Value semantics follow the field type.
| Control | Value |
|---|---|
| Text-like controls | element.value |
textarea | element.value |
select | element.value |
| Checkbox | element.checked |
| Radio | element.value when checked, otherwise null |
For a group of controls sharing a name, getValue() returns the value of the first registered control.
For example:
const plan = controller.getValue("plan");setValue(name, value)
Programmatically updates the value of one or more registered controls.
controller.setValue(
"email",
"[email protected]"
);The controller writes to the DOM and dispatches the appropriate input or change event so that related @bind state and dirty tracking remain synchronized.
Control behavior
| Control | Accepted behavior |
|---|---|
| Checkbox | Boolean, matching value string, or array of values for a group |
| Radio | Checks the control whose value matches |
| Other controls | Sets element.value; null / undefined become "" |
Examples:
controller.setValue("email", "[email protected]");
controller.setValue("newsletter", true);
controller.setValue("plan", "pro");For a checkbox group:
controller.setValue("features", [
"analytics",
"reports",
]);The corresponding DOM events are dispatched so that @bind can observe the programmatic change.
Form-level dirty and touched state are recalculated afterward.
Return value
Returns true if at least one registered field was updated.
Otherwise false.
setError(name, message)
Sets a field error manually without running its validators.
controller.setError(
"email",
"This email is already registered"
);This is particularly useful for server-side validation:
async save({ formData, controller }) {
const response = await api.save(
Object.fromEntries(formData)
);
if (!response.ok) {
controller.setError(
"email",
response.message
);
return;
}
controller.reset();
}Behavior
setError():
- trims string messages
- converts non-string messages to an empty string
- updates
errors[name] - recalculates
valid - does not run validators
If the supplied message is identical to the current error, the operation is a no-op.
An empty field name is also ignored.
Return value
Returns true if the error changed.
Returns false if nothing changed.
resetError(name)
Clears a field error without running validators.
controller.resetError("email");The resulting error is:
controller.errors.email === "";resetError():
- clears the error
- recalculates
valid - does not execute validators
If the field name does not exist in errors, or the error is already empty, nothing changes.
Return value
Returns true if the error changed.
Otherwise false.
Field Groups
A controller supports multiple controls sharing the same HTML name.
This is common for:
- radio groups
- checkbox groups
For example:
<input
type="radio"
name="plan"
value="free"
@validate="required"
/>
<input
type="radio"
name="plan"
value="pro"
@validate="required"
/>The controller maintains the individual registered fields while exposing name-based helpers.
| Method | Group behavior |
|---|---|
getField(name) | Returns an array of field states |
getValue(name) | Returns the first registered control's value |
setValue(name, value) | Applies the value according to control type |
resetField(name) | Resets every control in the group |
setError(name, message) | Uses one shared errors[name] entry |
resetError(name) | Clears the shared errors[name] entry |
When the final registered control for a name is removed, the corresponding errors[name] entry is removed as well.
See Working with Fields for complete group semantics.
Reactivity
Controller status and error state are reactive.
Components can react to changes caused by:
- validation
- field interaction
- asynchronous validation
- programmatic value updates
- error updates
- form submission
- form resets
For example:
<p @show="ud.forms.login.dirty">
You have unsaved changes.
</p>
<p @show="ud.forms.login.validating">
Checking...
</p>
<p @show="ud.forms.login.submitting">
Signing in...
</p>
<span @text="ud.forms.login.errors.email"></span>Computed values can derive higher-level application state:
computed: {
canSubmit() {
const form = this.ud.forms.login;
if (form.valid && !form.submitting) {
return undefined;
}
return "disabled";
},
},<button
type="submit"
@attr="disabled=canSubmit"
>
Sign in
</button>The controller therefore acts as the reactive boundary between the form runtime and application UI.
Server-Side Errors
Client-side validation cannot guarantee that a submission will be accepted by the server.
After a successful client validation pass, the submit handler can map server failures into controller errors:
methods: {
async save({ formData, controller }) {
const response = await api.save(
Object.fromEntries(formData)
);
if (!response.ok) {
controller.setError(
"email",
response.message || "Unable to save"
);
return;
}
controller.reset();
},
},The error immediately becomes part of the reactive errors object:
controller.errors.emailand therefore affects:
controller.validNo validator needs to be executed again.
This makes setError() appropriate for errors originating outside the client-side validation system, such as:
- duplicate email addresses
- authorization failures
- business-rule violations
- server-side validation
- optimistic submission failures
Controller Lifecycle
The controller is created and destroyed with the form's runtime registration.
| Event | Controller effect |
|---|---|
@form registers | Controller is created under ud.forms.<name> |
| Named validated field registers | errors[name] is initialized to "" when needed |
| Field receives focus | Aggregate touched state may become true |
| Field changes | Aggregate dirty state is recalculated |
| Validation starts | validating becomes active |
| Validation finishes | Errors and validating state are updated |
| Field unregisters | Its field state is removed |
| Last field with a name unregisters | errors[name] is removed |
| Form/component cleanup | Controller is removed from ud.forms |
Application code does not need to manually unregister the controller.
Example
The following example uses the controller for derived submit state, server-side errors, field reset, and form reset:
import { createComponent, html, render } from "udodi";
const AccountForm = createComponent({
name: "AccountForm",
state() {
return {
email: "",
};
},
computed: {
canSubmit() {
const form = this.ud.forms.login;
if (form.valid && !form.submitting) {
return undefined;
}
return "disabled";
},
emailDirty() {
return this.ud.forms.account.getField("email")?.dirty ?? false;
},
},
methods: {
required(value) {
return value?.trim() ? true : "Required";
},
email(value) {
if (!value) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? true
: "Invalid email";
},
async save({ formData, controller }) {
const response = await api.save(
Object.fromEntries(formData)
);
if (!response.ok) {
controller.setError(
"email",
response.message || "Unable to save"
);
return;
}
controller.reset();
},
clearEmail() {
this.ud.forms.account.resetField("email");
},
clearForm() {
this.ud.forms.account.reset();
},
},
template: html`
<form
@form="account"
@submit="save"
>
<label>
Email
<input
name="email"
type="email"
@bind="email"
@validate="required email"
/>
</label>
<span @text="ud.forms.account.errors.email"></span>
<p @show="emailDirty">
You changed this field.
</p>
<p @show="ud.forms.account.validating">
Validating...
</p>
<p @show="ud.forms.account.submitting">
Saving...
</p>
<button
type="submit"
@attr="disabled=canSubmit"
>
Save
</button>
<button
type="button"
@on="click=clearEmail"
>
Reset email
</button>
<button
type="button"
@on="click=clearForm"
>
Reset form
</button>
</form>
`,
});
render(AccountForm(), "#app");Common Patterns
Disable submission while invalid or submitting
computed: {
canSubmit() {
const form = this.ud.forms.login;
if (form.valid && !form.submitting) {
return undefined;
}
return "disabled";
},
},<button
type="submit"
@attr="disabled=canSubmit"
>
Sign in
</button>Show an individual error
<span @text="ud.forms.login.errors.email"></span>Show aggregate form state
<p @show="ud.forms.login.dirty">
Unsaved changes
</p>
<p @show="ud.forms.login.touched">
Form has been touched
</p>
<p @show="ud.forms.login.validating">
Validating...
</p>
<p @show="ud.forms.login.submitting">
Submitting...
</p>Set a server error
controller.setError(
"email",
"That email is already registered"
);Clear a server error
controller.resetError("email");Reset a single field
controller.resetField("email");Reset the entire form
controller.reset();Update a field programmatically
controller.setValue(
"email",
"[email protected]"
);API Summary
| API | Purpose |
|---|---|
valid | Aggregate validation state |
validating | Tracks active validation |
dirty | Aggregate field dirty state |
touched | Aggregate field touched state |
submitting | Tracks the submit pipeline |
submitted | Tracks successful submission |
validationMode | "sequential" or "parallel" |
errors | Map reactive field-name to error map |
reset() | Reset the entire form |
resetField(name) | Reset one field or field group |
getField(name) | Read normalized field state |
getValue(name) | Read a field's current value |
setValue(name, value) | Programmatically update field value |
setError(name, message) | Set a manual/server error |
resetError(name) | Clear a manual/server error |