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 and automatically garbage collected with their subjects. The same object always returns the same form instance:
const form1 = Form.get(model);
const form2 = Form.get(model);
// form1 === form2 (same instance)
⚠️ 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)Get errors for specific fields or the entire form:
// Field-specific errors
form.getErrors('email'); // Set<string> - only if reported
form.getErrors('email', true); // include pre-reported
// All errors including nested forms
form.getAllErrors(); // Set<string>
form.getAllErrors('address'); // errors for address field and nested address form
// First error message
form.firstErrorMessage; // string | undefined
Report errors to make them visible:
// Report errors on all fields and sub-forms
form.reportError();
// Typically called when submit fails validation
if (!form.isValid) {
form.reportError();
}
Forms manage the complete submission lifecycle with three phases: willSubmit, submit, and didSubmit.
// Basic submission
await form.submit();
// Force submission even if not ready
await form.submit({ force: true });
Handlers are executed in registration order:
// Pre-submission validation or preparation
const dispose1 = form.addHandler('willSubmit', async (abortSignal) => {
console.log('Preparing to submit...');
return true; // return false to cancel submission
});
// Main submission logic (executed serially)
const dispose2 = form.addHandler('submit', async (abortSignal) => {
try {
await api.saveUser(model);
return true; // success
} catch (error) {
console.error(error);
return false; // failure
}
});
// Post-submission cleanup
const dispose3 = form.addHandler('didSubmit', (succeed) => {
if (succeed) {
console.log('Saved successfully!');
} else {
form.reportError();
}
});
// Remove handlers when done
dispose1();
dispose2();
dispose3();
The submission process executes handlers in three phases:
willSubmit - Called before submission starts. All handlers run in parallel. If any handler returns false, submission is cancelled.
submit - Main submission handlers. These are executed serially (one after another) in registration order. If any handler returns false, remaining handlers are skipped and submission fails.
didSubmit - Called after submission completes (success or failure). Receives a boolean indicating whether submission succeeded. These handlers run synchronously within a MobX action.
The submission can be cancelled mid-flight by calling form.submit({ force: true }), which aborts the current submission via the AbortSignal and starts a new one.
After successful submission, forms automatically reset (clearing dirty state and field states).
// 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>
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), decoupling validation timing from user input for optimal UX.
Mark changes as "intermediate" while the user is typing to delay validation and error reporting. 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.id,
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.id,
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);
}
@computed
get firstFieldId() {
return this.fields.at(0)?.id;
}
@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.firstFieldId,
'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.idthis.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>