MobX library for non-intrusive class-based model enhancement. Acting as a sentinel, it provides change detection, reactive validation, and form integration capabilities without contamination.
npm install @mobx-sentinel/formForm 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:
``ts`
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:
`ts`
const form1 = Form.get(model);
const form2 = Form.get(model);
// form1 === form2 (same instance)
#### Multiple Forms Per Subject
Use a symbol key to maintain multiple independent forms for the same object:
`ts
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:
`ts
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:
`ts
// 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
`
#### Submission Readiness
canSubmit checks if the form can be submitted based on:allowSubmitInvalid
- Not currently busy (submitting or validating)
- Valid (unless is enabled)allowSubmitNonDirty
- Dirty (unless is enabled)
Get errors for specific fields or the entire form:
`ts
// Field-specific errors
form.getErrors('email'); // Set
form.getErrors('email', true); // include pre-reported
// All errors including nested forms
form.getAllErrors(); // Set
form.getAllErrors('address'); // errors for address field and nested address form
// First error message
form.firstErrorMessage; // string | undefined
`
Report errors to make them visible:
`ts
// 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.
`ts
// Basic submission
await form.submit();
// Force submission even if not ready
await form.submit({ force: true });
`
#### Adding Submission Handlers
Handlers are executed in registration order:
`ts
// 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();
`
#### Submission Lifecycle
The submission process executes handlers in three phases:
1. willSubmit - Called before submission starts. All handlers run in parallel. If any handler returns false, submission is cancelled.
2. 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.
3. 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).
`ts
// 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:
`ts
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
`
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:
1. Use @mobx-sentinel/react - Pre-built bindings for React components (InputBinding, CheckBoxBinding, SubmitButtonBinding, etc.)
2. Build your own bindings - Implement custom bindings for your framework or specific use cases
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:
- A single field
- Multiple fields
- The entire form
Bindings encapsulate the logic for connecting form state to UI components, managing field state changes, and handling user interactions.
#### Working with Fields
Fields track individual input state and provide methods for managing user interactions:
`ts
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
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
`
##### Intermediate vs Final Changes
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):
`ts
onChange={(e) => {
model.email = e.target.value;
field.markAsChanged('intermediate'); // Don't report errors yet
}}
onBlur={() => {
field.finalizeChangeIfNeeded(); // Finalize and report errors
}}
`
#### Field Binding Example
Here's a real-world text input binding similar to the implementation in @mobx-sentinel/react:
`ts
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
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
this.field.finalizeChangeIfNeeded(); // Ensure to report errors when they left the input
this.config.onBlur?.(e);
};
onFocus = (e: React.FocusEvent
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,
};
}
}
`
#### Checkbox Binding Example
Checkboxes use immediate finalization since there's no intermediate state:
`ts
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
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
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,
};
}
}
`
#### Multi-Field Binding Example
Bindings can work with multiple fields, useful for components like labels that need to aggregate state:
`ts
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.
#### Form Binding Example
Bindings can also operate on the entire form, useful for submit buttons:
`ts
class SubmitButtonBinding implements FormBinding {
constructor(
private readonly form: 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
this.form.submit().catch((e) => void e);
this.config.onClick?.(e);
};
onMouseOver = (e: React.MouseEvent
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,
};
}
}
`
Use form.bind() to create binding props and spread them directly into components:
`tsx
const model = new User();
const form = Form.get(model);
{/ Bind to a single field /}
getter: () => model.email,
setter: (value) => model.email = value,
})} />
{/ Bind with additional configuration /}
type: 'password',
getter: () => model.password,
setter: (value) => model.password = value,
})} />
{/ Bind to multiple fields /}
{/ Bind to the form /}
``
Bindings are cached and reused. The same binding constructor with the same field/configuration returns the same instance. Configuration can be updated on subsequent calls while maintaining the same binding instance.