Form and bindings for MobX-based form management with validation and submission handling.
Form is a reactive form management system built on MobX that tracks form state, validation, and submission lifecycle. It automatically manages dirty state, field-level tracking, and nested form hierarchies, making it easy to build complex forms with proper validation and user feedback.
It leverages @mobx-sentinel/core for dirty state tracking (Watcher) and validation (Validator).
Use Form.get() to retrieve or create a form instance for an object:
const model = new MyModel();
const form = Form.get(model);
Form instances are cached. The same object always returns the same form instance:
const form1 = Form.get(model);
const form2 = Form.get(model);
// form1 === form2 (same instance)
A form observes its subject through MobX reactions, those of the subject's Watcher and of the form's fields, so it is garbage collected together with the subject only if everything they observe is too (see Getting a Watcher Instance). In particular, a @nested object that outlives the subject keeps the subject and its forms alive through the watcher, unless the property is excluded with @unwatch. The fields of a form observe the validation state, that of nested objects included, only while a report waits for the validation to settle.
⚠️ Note: Form.get() starts change tracking immediately because it creates a Watcher instance as part of the form initialization process. See Starting a Watcher for details.
Use a symbol key to maintain multiple independent forms for the same object:
const editFormKey = Symbol('edit');
const previewFormKey = Symbol('preview');
const editForm = Form.get(model, editFormKey);
const previewForm = Form.get(model, previewFormKey);
// editForm !== previewForm (different instances)
Forms automatically track sub-forms when using the @nested annotation:
class Address {
@observable street = '';
@observable city = '';
}
class User {
@observable name = '';
@nested @observable address = new Address();
@nested @observable previousAddresses = [new Address()];
}
const user = new User();
const userForm = Form.get(user);
// Access sub-forms
const addressForm = Form.get(user.address);
const prevAddressForm = Form.get(user.previousAddresses[0]);
// Sub-forms are tracked in the parent
userForm.subForms.get('address'); // addressForm
userForm.subForms.get('previousAddresses.0'); // prevAddressForm
When sub-forms become dirty, parent forms automatically become dirty too. This allows validation and dirty checking to bubble up through the form hierarchy.
Forms provide several reactive state properties:
// Dirty state - whether the form has changes
form.isDirty; // boolean
// Validation state
form.isValid; // boolean
form.invalidFieldCount; // number of invalid fields
form.invalidFieldPathCount; // includes nested forms
form.isValidating; // boolean - async validation in progress
// Submission state
form.isSubmitting; // boolean
form.isBusy; // true if submitting or validating
// Combined state
form.canSubmit; // true if ready to submit
canSubmit checks if the form can be submitted based on:
allowSubmitInvalid is enabled)allowSubmitNonDirty is enabled)The validator tracks errors from the start, but form.getErrors() and the bindings expose a field's errors only after they have been reported. See Smart Error Reporting for when that happens.
// Field-specific errors
form.getErrors('email'); // Set<string> - empty until reported
form.getErrors('email', true); // include errors that have not been reported yet
// All errors including nested forms, regardless of reporting
form.getAllErrors(); // Set<string>
form.getAllErrors('address'); // errors for address field and nested address form
// First error message, regardless of reporting
form.firstErrorMessage; // string | null
Report errors to make them visible:
// Report errors on all fields and sub-forms
form.reportError();
// e.g., when a submission fails
form.addHandler('didSubmit', (succeed) => {
if (!succeed) form.reportError();
});
Forms manage the complete submission lifecycle with three phases, each with its own handlers: willSubmit decides whether to go ahead, submit does the work and reports whether it succeeded, and didSubmit reacts to the outcome.
// Basic submission (resolves `false` without running any handler unless `canSubmit` is true)
const succeed = await form.submit();
// Force submission even if not ready (cancels a submission in progress)
await form.submit({ force: true });
Handlers are executed in registration order:
// Before submitting: veto or prepare the submission
const dispose1 = form.addHandler('willSubmit', async () => {
console.log('Submitting...');
return confirm('Save changes?'); // false stops the submission (it fails)
});
// Perform the submission and report whether it succeeded (executed serially)
const dispose2 = form.addHandler('submit', async (abortSignal) => {
try {
await api.saveUser(model, { signal: abortSignal }); // cancels the request when a newer submission replaces this one
return true; // succeeded
} catch (error) {
console.error(error);
return false; // failed
}
});
// After submitting: react to the outcome
const dispose3 = form.addHandler('didSubmit', (succeed) => {
if (succeed) {
showToast('Saved successfully!');
} else {
form.reportError();
}
});
// Remove handlers when done
dispose1();
dispose2();
dispose3();
The submission process executes handlers in three phases:
willSubmit - A veto or preparation step before anything is submitted, and a place to log the attempt. Handlers run serially (one after another) in registration order. If a handler returns false or throws, the remaining handlers and the submit phase are skipped and the submission fails.
submit - Performs the submission. Handlers run serially in registration order, and each returns whether the submission succeeded. If a handler returns false or throws, the remaining handlers are skipped and the submission fails.
didSubmit - Called once with the outcome (true if the submission succeeded) after a submission finishes, unless a newer submission cancelled it. Use it for what follows the submission, for example:
These handlers run synchronously within a MobX action.
After a successful submission, the form resets automatically (clearing dirty state and field states).
submit Handler ReturnsThe boolean a submit handler returns reports whether the submission succeeded. It becomes the outcome didSubmit handlers receive (with several submit handlers, true only if every one of them returns true), and the form resets automatically only when the outcome is true. It is meaningful even without any async work: return false whenever the submission did not go through, e.g. when the server rejects the data or a precondition does not hold.
The return value is not how a handler stops a cancelled submission; the form enforces cancellation itself (see Cancelling a Submission). Fallback or cleanup specific to the request belongs in the handler's own try/catch, with finally for cleanup that must always run.
didSubmit handlers receive only the boolean. When the next step needs what the submission produced, such as the created record or its ID, have the submit handler store it where the didSubmit handler or the component can read it:
let createdUserId: string | null = null;
form.addHandler('submit', async (abortSignal) => {
const user = await api.saveUser(model, { signal: abortSignal });
createdUserId = user.id; // keep the result for the next step
return true;
});
form.addHandler('didSubmit', (succeed) => {
if (succeed && createdUserId) openUserPage(createdUserId);
});
Calling form.submit({ force: true }) while a submission is in progress cancels it: its AbortSignal is aborted and a new submission starts. The cancelled submission invokes no further willSubmit or submit handlers, resolves false whatever its handlers return, and neither calls didSubmit handlers nor resets the form, while isSubmitting stays true until the latest submission settles. The latest submission reports the outcome, so didSubmit is called once for it, even if the cancelled submission settles later.
The handler that is running when the submission is cancelled is not interrupted, so pass the signal to its async work (e.g. fetch(url, { signal })) to cancel that work too. Since didSubmit is not called for a cancelled submission, release anything a handler acquired in a finally block inside that handler.
// Mark form as dirty
form.markAsDirty();
// Reset form state (clears dirty, fields, sub-forms)
form.reset();
// Note: reset() does NOT clear validation errors
// Errors are managed by the Validator and remain until revalidation
Configure forms globally or per-instance:
import { configureForm } from '@mobx-sentinel/form';
// Global configuration (affects all forms)
configureForm({
autoFinalizationDelayMs: 2000, // delay before intermediate input is finalized
allowSubmitNonDirty: true, // allow submitting unchanged forms
allowSubmitInvalid: true, // allow submitting invalid forms
});
// Reset global configuration
configureForm(true);
// Per-form configuration (overrides global)
form.configure({
autoFinalizationDelayMs: 5000,
allowSubmitNonDirty: false,
});
// Reset per-form configuration
form.configure(true);
// Access current configuration
form.config; // Readonly<FormConfig>
A form is usually invalid long before the user has done anything wrong: required fields start out empty, and an email address stays invalid until its last character is typed. Showing every error the moment it exists scolds users for steps they have not reached yet; holding every error back until submission leaves them guessing.
The form keeps two concerns apart:
form.isValid, form.canSubmit, field.hasErrors and field.errors are always up to date — enough to disable a submit button or skip an autosave without showing the user anything.field.isErrorReported, form.getErrors() and the aria-invalid attribute set by the bindings follow it.The standard bindings of @mobx-sentinel/react behave as follows. You can try each step in the example app.
| When the user... | They see |
|---|---|
| opens a form that starts out invalid | No errors. The submit button is disabled, but no field is marked invalid. |
| moves through fields without changing them | No errors. Focusing a field does not report it. |
| starts typing into a text field | No errors while typing. |
| leaves the field, or stops typing for 3 seconds | The errors of that field. |
| picks an option in a checkbox, radio button or select box | The errors of that field, right away. |
| keeps editing a field whose errors have been shown | Errors that follow the value: a fix clears the error as soon as validation confirms it, and a new mistake shows up the same way. |
| hovers the submit button, even while it is disabled | Every outstanding error of the form and its sub-forms. |
| then reveals another field or adds another item | No errors on the new field or item until the user works on it, or until the form reports again. |
| submits successfully, or the form is reset | No errors. Reporting starts over. |
A text input cannot tell when the user is done, so InputBinding marks every keystroke as an intermediate change — a value that may still be incomplete, like user@ in an email field — and reports nothing yet. The change becomes final, and the field's errors are reported, when either:
onBlur calls field.finalizeChangeIfNeeded()), orautoFinalizationDelayMs, 3 seconds by default. Every keystroke restarts the timer, so users who never leave the field, such as on the last field before a disabled submit button, still get feedback.Checkboxes, radio buttons and select boxes cannot hold a partial value, so their bindings make a final change right away (field.markAsChanged()).
Focusing a field marks it as touched (field.isTouched) but reports nothing. Leaving a field without changing it reports nothing either, since finalizeChangeIfNeeded() only acts on a pending intermediate change. A user who tabs through the form to get an overview does not turn it red.
A reported field stays reported until the form is reset, and from then on its errors follow every change, intermediate ones included. When the user fixes a mistake, the error disappears as soon as validation confirms the fix, so users learn right away that they got it right instead of when they leave the field. A new mistake in that field shows up the same way, while the user is still typing.
form.reportError() reports every field of the form and, recursively, of its sub-forms. Call it when users need to see everything that stands in their way:
SubmitButtonBinding calls it when the pointer moves over the button. The button is disabled while the form is invalid, so hovering it is exactly when users wonder why, and they get the answer before they try to click.didSubmit handler can call it when a submission fails, for example after the errors returned by the server have been fed back into the model.Reporting is an action, not a mode: it covers the fields and sub-forms that exist when it is called. A field exists once a binding, form.getField() or form.getErrors() has asked for it — in a React app, once it has been rendered. Anything that comes later starts unreported:
A line the user has just added therefore does not open with "Description is required". Its fields are reported by the user's own changes, or by the next form.reportError(), such as when the user hovers the submit button again.
Each nested object and array element has its own form (Form.get(invoice.billTo), Form.get(invoice.lineItems[0])) with its own reporting state. Nothing has to be passed between them: the parent finds its sub-forms through the @nested annotation of the model.
form.reportError() on the parent reaches every sub-form, while reporting a sub-form, or finishing a change in one of its fields, leaves the parent and the sibling sub-forms untouched.reportError().Validation handlers are throttled (100 ms by default), and asynchronous handlers can take much longer. Reporting right away could flash a result computed for an older value: an error that is about to go away, or a clean state that a server-side check is about to overturn.
So when a field becomes reported, field.isErrorReported stays undefined until the validator of its form, including nested validators and asynchronous handlers, has settled. Only then does it show the result. After that, errors change only when new results arrive, so an error is not cleared while its revalidation is still running and does not blink off and on while the user types.
field.isErrorReported is undefined until the field is reported, and true or false afterwards, which maps directly onto aria-invalid: the attribute is omitted until the field is reported. The standard bindings pass it through as is, so assistive technologies learn about an error at the same moment sighted users see it.
A label and its control find each other through an id, so every field carries one: field.stableId, which the standard bindings put in id, htmlFor, and a radio group's name. The form has one too, form.stableId.
A form's stable id starts as its own identity, form.id, and until it is assigned another, each field's is its own identity too, field.id: unique on the page, but different in every process. That is all a client-rendered app needs.
Server-side rendering needs more, because the markup is built in one process and hydrated in another. Assign the form a stable id that both arrive at, and its fields' stable ids build on it:
form.stableId = `invoice-form-${invoice.id}`;
form.getField('customerEmail').stableId; // "invoice-form-42:customerEmail"
| Until assigned | After form.stableId = id |
|
|---|---|---|
form.stableId |
form.id |
<id> |
field.stableId |
field.id |
<id>:<field name> |
For anything that reaches the DOM, use stableId rather than id, so it keeps matching once the form's stable id is assigned.
The id is used as is, so nothing else on the page may use it. In React, useFormSSR from @mobx-sentinel/react assigns useId(), which guarantees that: no other call returns the same value, so nothing in the model has to be involved. It gives the form its own id back when the component unmounts. A key from your own data works too, as long as it is unique on the page. Assign it before binding: bindings read the stable id as they are called, and it is not reactive.
A sub-form is a separate instance with its own stable id. Assign it where the sub-form is rendered, so no component has to know what its ancestors did.
A field's stable id contains :, which is fine for htmlFor, aria-* attributes and document.getElementById(). In a CSS selector, escape it with CSS.escape().
The behavior is split between the bindings and a few methods of FormField, so each part can be changed where it is needed:
The pause before intermediate input is reported: configureForm({ autoFinalizationDelayMs }) for all forms, or form.configure({ autoFinalizationDelayMs }) for one form.
Reporting on every keystroke: in a custom binding, call field.markAsChanged() (a final change) in onChange instead of field.markAsChanged('intermediate').
Reporting from your own code: field.reportError() for one field, form.reportError() for the whole form.
Showing errors regardless of reporting: form.getErrors(fieldName, true) or field.errors.
Starting over: form.reset() clears the reporting state of every field and sub-form, and runs automatically after a successful submission. The validator keeps its errors; they stay hidden until they are reported again.
Letting keyboard users find out what is wrong: revealing errors on hover needs a pointer, and a disabled button can't be focused with the keyboard. To keep the button enabled, allow invalid submissions and report the errors from a willSubmit handler that cancels the submission:
form.configure({ allowSubmitInvalid: true });
form.addHandler('willSubmit', async () => {
if (form.isValid) return true;
form.reportError();
return false;
});
Popular form libraries decide when an error appears in one of two ways. Some run validation on chosen events and show whatever the last run found: React Hook Form's mode, VeeValidate's validateOn* options, TanStack Form's per-event validators. Others validate on every change and leave the decision to a display condition that the app writes from flags such as touched or submitFailed: Formik, Final Form, Angular. mobx-sentinel validates continuously, like the latter, but makes the display decision itself and keeps it per field as isErrorReported.
Compared with the defaults and documented setups of those libraries (checked in September 2026 against React Hook Form 7, Formik 2, Final Form 5, TanStack Form 1, VeeValidate 4, Angular 22, mobx-react-form 7 and formstate):
touched conditions recommended by Formik, Final Form and Angular, VeeValidate's <Field>, React Hook Form's onBlur and onTouched modes, and mobx-react-form's defaults, tabbing past an empty required field flags it. Here, a field is reported only after its value has changed, or when the whole form is reported.change, an email address is flagged from its first keystroke; when they follow blur, users who stop typing without leaving the field get no feedback. The standard bindings report on whichever comes first: leaving the field, or a pause. React Hook Form's opt-in delayError comes closest, holding errors back for a set time and removing them instantly. Otherwise, the nearest options debounce validation itself, as formstate and mobx-react-form do, which holds back the validity along with the message.reValidateMode), and TanStack Form's revalidateLogic switches to modeAfterSubmission. Display conditions built on form-wide flags, like Final Form's submitFailed or the form.submitted check in Angular Material's default error state matcher, show errors on fields that appear later, before the user has touched them. Formik's submit marks every key in values as touched, so a conditional field whose key is in initialValues shows its error the moment it appears. Here, form.reportError() covers what exists when it is called, and fields and sub-forms that appear later still wait for the user.SubmitButtonBinding keeps the button disabled until the form can be submitted and reports every error when the pointer moves over it. For keyboard users, see Adjusting the Behavior.These defaults follow widely cited guidance on inline validation: wait until users have finished with a field before showing its errors (Nielsen Norman Group), don't flag fields they have only tabbed through (Smashing Magazine), and once an error is shown, update it as they correct the input (Baymard Institute).
Bindings connect form state to UI components. They encapsulate the logic for creating props that can be spread onto input elements.
The @mobx-sentinel/form package provides only the API for creating bindings—it does not include any pre-built binding implementations. You have two options:
@mobx-sentinel/react - Pre-built bindings for React components (InputBinding, CheckBoxBinding, SubmitButtonBinding, etc.)The examples below demonstrate how to create custom bindings. They are based on the actual implementations in @mobx-sentinel/react.
A binding class implements the FormBinding interface and can bind to:
Bindings encapsulate the logic for connecting form state to UI components, managing field state changes, and handling user interactions.
Fields track individual input state and provide methods for managing user interactions:
const field = form.getField('email');
// Field state (all reactive)
field.isTouched; // user has focused the field
field.isChanged; // value has changed
field.isIntermediate; // typing in progress (partial input)
// Validation state
field.hasErrors; // boolean - has validation errors
field.errors; // Set<string> of error messages
field.isErrorReported; // undefined | false | true - for conditional display
// State management methods
field.markAsTouched(); // typically on focus
field.markAsChanged('intermediate'); // while typing
field.markAsChanged('final'); // on blur or enter
field.finalizeChangeIfNeeded(); // typically on blur
field.reportError(); // show errors to user
field.reset(); // clear all state
Fields distinguish between "intermediate" changes (typing in progress) and "final" changes (committed), so that a field's errors are first reported once its input is complete, not on the first keystroke. See Smart Error Reporting for the behavior users experience.
Mark changes as "intermediate" while the user is typing to delay error reporting; validation itself keeps running as the model changes. Intermediate values automatically finalize after a delay (configurable via autoFinalizationDelayMs), or you can manually trigger finalizeChangeIfNeeded() to reflect changes immediately:
onChange={(e) => {
model.email = e.target.value;
field.markAsChanged('intermediate'); // Don't report errors yet
}}
onBlur={() => {
field.finalizeChangeIfNeeded(); // Finalize and report errors
}}
Here's a real-world text input binding similar to the implementation in @mobx-sentinel/react:
import { FormBinding, FormField } from '@mobx-sentinel/form';
import { makeObservable, computed, action } from 'mobx';
class InputBinding implements FormBinding {
constructor(
private readonly field: FormField, // Accepting single field
public config: {
getter: () => string | null;
setter: (value: string) => void;
id?: string;
onChange?: (e: React.ChangeEvent) => void;
onFocus?: (e: React.FocusEvent) => void;
onBlur?: (e: React.FocusEvent) => void;
}
) {
makeObservable(this);
}
@computed
get value() {
return this.config.getter() ?? ''; // Read the value from the model
}
@action
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.config.setter(e.currentTarget.value); // Update the value from the input
this.field.markAsChanged('intermediate'); // While editing, delay error reporting
this.config.onChange?.(e); // Support extending handlers via config
};
onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
this.field.finalizeChangeIfNeeded(); // Ensure to report errors when they left the input
this.config.onBlur?.(e);
};
onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
this.field.markAsTouched();
this.config.onFocus?.(e);
};
@computed
get errorMessages() {
if (!this.field.isErrorReported) return null; // Check whether to report errors
return Array.from(this.field.errors).join(', ') || null;
}
// The outport props: the returned value is passed to the view component
get props() {
return {
type: 'text',
value: this.value,
id: this.config.id ?? this.field.stableId,
onChange: this.onChange,
onFocus: this.onFocus,
onBlur: this.onBlur,
'aria-invalid': this.field.isErrorReported,
'aria-errormessage': this.errorMessages ?? undefined,
};
}
}
Checkboxes use immediate finalization since there's no intermediate state:
class CheckBoxBinding implements FormBinding {
constructor(
private readonly field: FormField, // Accepting single field
public config: {
getter: () => boolean;
setter: (value: boolean) => void;
id?: string;
onChange?: (e: React.ChangeEvent) => void;
onFocus?: (e: React.FocusEvent) => void;
}
) {
makeObservable(this);
}
@computed
get checked() {
return this.config.getter();
}
@action
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.config.setter(e.currentTarget.checked);
this.field.markAsChanged(); // Defaults to 'final'; No delay is needed for checkboxes
this.config.onChange?.(e);
};
onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
this.field.markAsTouched();
this.config.onFocus?.(e);
};
@computed
get errorMessages() {
if (!this.field.isErrorReported) return null;
return Array.from(this.field.errors).join(', ') || null;
}
get props() {
return {
type: 'checkbox',
id: this.config.id ?? this.field.stableId,
checked: this.checked,
onChange: this.onChange,
onFocus: this.onFocus,
'aria-invalid': this.field.isErrorReported,
'aria-errormessage': this.errorMessages ?? undefined,
};
}
}
Bindings can work with multiple fields, useful for components like labels that need to aggregate state:
class LabelBinding implements FormBinding {
constructor(
private readonly fields: FormField[], // Accepting multiple fields
public config: {
htmlFor?: string;
}
) {
makeObservable(this);
}
// Not @computed: `stableId` is composed from a plain field on the form,
// so while observed, a computed would keep the first id it saw
get firstFieldStableId() {
return this.fields.at(0)?.stableId;
}
@computed
get firstErrorMessage() {
for (const field of this.fields) {
if (!field.isErrorReported) continue;
for (const error of field.errors) {
return error;
}
}
return null;
}
get props() {
return {
htmlFor: this.config.htmlFor ?? this.firstFieldStableId,
'aria-invalid': !!this.firstErrorMessage,
'aria-errormessage': this.firstErrorMessage ?? undefined,
};
}
}
This binding aggregates error states from multiple fields, showing the first error message if any field has errors.
Bindings can also operate on the entire form, useful for submit buttons:
class SubmitButtonBinding implements FormBinding {
constructor(
private readonly form: Form<unknown>, // Accepting the form
public config: {
onClick?: (e: React.MouseEvent) => void;
onMouseOver?: (e: React.MouseEvent) => void;
}
) {
makeObservable(this);
}
@computed
get busy() {
return this.form.isSubmitting || this.form.isValidating;
}
onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
this.form.submit().catch((e) => void e);
this.config.onClick?.(e);
};
onMouseOver = (e: React.MouseEvent<HTMLButtonElement>) => {
this.form.reportError(); // Show errors on hover (like attempting to click)
this.config.onMouseOver?.(e);
};
get props() {
return {
onClick: this.onClick,
onMouseOver: this.onMouseOver,
disabled: !this.form.canSubmit,
'aria-busy': this.busy,
'aria-invalid': !this.form.isValid,
};
}
}
Follow these patterns when creating bindings:
onChange?: ...)this.config.onChange?.(e))id?: string)this.config.id ?? this.field.stableId (stableId, not id -- see Element IDs)this.config.disabled || this.disabled?? "" for strings)valueAsNumber returns NaN or valueAsDate returns nullfield.markAsTouched()field.finalizeChangeIfNeeded()field.isErrorReported before showing errorsid on inputs and htmlFor on labels)aria-invalid based on field.isErrorReportedaria-errormessage with error text, or undefined if no errorsaria-busy to indicate loading statesBindings are cached and reused. The same binding constructor with the same binding key returns the same instance. Configuration can be updated on subsequent calls while maintaining the same binding instance.
The binding key consists of three components:
cacheKey property in configurationUse form.bind() to create binding props and spread them directly into components:
const model = new User();
const form = Form.get(model);
{/* Bind to a single field */}
<input {...form.bind('email', InputBinding, {
getter: () => model.email,
setter: (value) => model.email = value,
})} />
{/* Bind with additional configuration */}
<input {...form.bind('password', InputBinding, {
type: 'password',
getter: () => model.password,
setter: (value) => model.password = value,
})} />
{/* Bind to multiple fields */}
<label {...form.bind(['email', 'password'], LabelBinding)}>Credentials</label>
{/* Bind to the form */}
<button {...form.bind(SubmitButtonBinding)}>Submit</button>