@mobx-sentinel/form
v0.3.4
Published
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.
Downloads
317
Maintainers
Readme
mobx-sentinel/form
Form and bindings for MobX-based form management with validation and submission handling.
Form
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).
Getting a Form Instance
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)Multiple Forms Per Subject
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)Nested/Array Forms
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'); // prevAddressFormWhen sub-forms become dirty, parent forms automatically become dirty too. This allows validation and dirty checking to bubble up through the form hierarchy.
Form State
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 submitSubmission Readiness
canSubmit checks if the form can be submitted based on:
- Not currently busy (submitting or validating)
- Valid (unless
allowSubmitInvalidis enabled) - Dirty (unless
allowSubmitNonDirtyis enabled)
Error Handling
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 | undefinedReport 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();
}Submitting Forms
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 });Adding Submission Handlers
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();Submission Lifecycle
The submission process executes handlers in three phases:
willSubmit- Called before submission starts. All handlers run in parallel. If any handler returnsfalse, submission is cancelled.submit- Main submission handlers. These are executed serially (one after another) in registration order. If any handler returnsfalse, 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).
Managing State
// 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 revalidationConfiguration
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>Binding
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:
- Use @mobx-sentinel/react - Pre-built bindings for React components (InputBinding, CheckBoxBinding, SubmitButtonBinding, etc.)
- 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.
Creating Binding Classes
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:
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 stateIntermediate 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):
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:
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,
};
}
}Checkbox Binding Example
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,
};
}
}Multi-Field Binding Example
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.
Form Binding Example
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,
};
}
}Using Bindings
Use 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>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.
