Validator
Perform synchronous and asynchronous validation on MobX models with automatic throttling.
Validator is designed for declarative, reactive validation - such as form validation with automatic field-level and cross-field checks, throttled async API checks (e.g., username availability), complex multi-field validation with dependencies, hierarchical validation of nested structures with error aggregation, throttled real-time feedback during user input, and submission guards that prevent invalid data from being submitted.
Getting a Validator Instance
Section titled “Getting a Validator Instance”Use Validator.get() to retrieve or create a validator instance for an object:
const model = new MyModel();const validator = Validator.get(model);Validator instances are cached. The same object always returns the same validator instance:
const validator1 = Validator.get(model);const validator2 = Validator.get(model);// validator1 === validator2 (same instance)Validation handlers are MobX reactions, so a validator is garbage collected together with its target only if everything its handlers observe is too, as with any reaction. Dispose a handler that reads something outliving the target, such as a store, with the function that makeValidatable, addSyncHandler or addAsyncHandler returns.
Use Validator.getSafe() to get a validator without throwing errors for non-objects:
const validator = Validator.getSafe(maybeObject);// Returns null if maybeObject is not an objectUsing makeValidatable()
Section titled “Using makeValidatable()”The makeValidatable() function is a convenient shorthand for adding validation handlers:
class Model { @observable email = "";
constructor() { makeObservable(this);
// Sync validation makeValidatable(this, (builder) => { ... }); // Async validation makeValidatable(this, () => this.email, async (email, builder, abortSignal) => { ... }); }}This is equivalent to:
const validator = Validator.get(this);
validator.addSyncHandler((builder) => { ... });validator.addAsyncHandler(() => this.email, async (email, builder, abortSignal) => { ... });⚠️ Important:
- If you're using
makeObservable()ormakeAutoObservable(), callmakeValidatable()after them to ensure observability is set up first. - Handlers run immediately by default on registration (
initialRun: true). SetinitialRun: falseto wait for the first change.
Synchronous Validation
Section titled “Synchronous Validation”Validators run with a default delay of 100ms to throttle rapid changes. This delay acts as throttling, not debouncing - the handler will eventually run even during continuous changes.
Key behaviors:
- Default delay:
Validator.defaultDelayMs(100ms) - Configurable via
delayMsoption in handler options - Multiple rapid changes are batched and validated once after the delay
- Handlers run immediately by default on registration unless
initialRun: falseis set
class FormModel { @observable email = ""; @observable age = 0;
constructor() { makeObservable(this);
makeValidatable(this, (builder) => { if (!this.email.includes("@")) { builder.invalidate("email", "Invalid email format"); } if (this.age < 18) { builder.invalidate("age", "Must be 18 or older"); } }); }}
const form = new FormModel();const validator = Validator.get(form);
// Multiple rapid changes are throttledrunInAction(() => { form.email = "test";});runInAction(() => { form.email = "invalid"; // Only validated once after delay});
// Wait for validation to completeawait validator.waitForValidation();
validator.isValid // falsevalidator.invalidKeys // Set(["email"])validator.invalidKeyCount // 1
// Get error messagesvalidator.getErrorMessages("email") // Set(["Invalid email format"])validator.firstErrorMessage // "Invalid email format"
// Check for errorsvalidator.hasErrors("email") // truevalidator.hasErrors("age") // false
// Get detailed errorsfor (const [keyPath, error] of validator.findErrors(KeyPath.Self)) { console.log(`${keyPath}: ${error.message}`);}Validator State Properties
Section titled “Validator State Properties”Access the current validation state through these reactive properties:
const validator = Validator.get(model);
// Validity statevalidator.isValid // boolean - no validation errorsvalidator.invalidKeys // Set<KeyPath> - direct property errors only (e.g., "name", "email")validator.invalidKeyPaths // Set<KeyPath> - all errors including nested (e.g., "child.email", "items.0.age")validator.invalidKeyCount // number - count of direct errorsvalidator.invalidKeyPathCount // number - count of all errors
// Validation progressvalidator.isValidating // boolean - any validation in progress (reactionState + asyncState > 0)validator.reactionState // number - pending sync reactions (0 or more)validator.asyncState // number - pending async jobs (0 or more)validator.waitForValidation() // Promise<void> - resolves once isValidating is false
// Error queriesvalidator.firstErrorMessage // string | undefined - first error foundvalidator.getErrorMessages(keyPath) // Set<string> - errors for a pathvalidator.hasErrors(keyPath, deep?) // boolean - check for errorsvalidator.findErrors(keyPath, deep?) // Iterator<[KeyPath, ValidationError]>Understanding validation states:
reactionState: Counts pending/running synchronous validation reactionsasyncState: Counts pending/running asynchronous validation jobsisValidating: Convenience property that'struewhen either state is non-zero- Multiple handlers can add multiple errors to the same key - they accumulate in a Set
Waiting for Validation
Section titled “Waiting for Validation”Errors don't reflect a change right away: handlers run after the delay, and async handlers take as long as their work does. Await waitForValidation() before reading the result; it resolves once the validation completes:
runInAction(() => { form.email = "invalid";});
await validator.waitForValidation();
validator.isValid // falsevalidator.getErrorMessages("email") // Set(["Invalid email format"])Key behaviors:
- A shorthand for
await when(() => !validator.isValidating) - Resolves right away if nothing is being validated
- Waits for nested validators as well
- Deadlocks when awaited in an async handler of the same validator or of a nested one, since the handler is part of the validation it waits for
To stop waiting, pass an AbortSignal. When it's aborted, the promise rejects with the signal's reason, while the validation itself goes on. AbortSignal.timeout() puts a time limit on the wait:
try { await validator.waitForValidation({ signal: AbortSignal.timeout(5000) });} catch (e) { // A "TimeoutError" DOMException after 5 seconds}