Skip to content

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.

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 addValidation, 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 object

The addValidation() function is a convenient shorthand for adding validation handlers:

class Model {
@observable email = "";
constructor() {
makeObservable(this);
// Sync validation
addValidation(this, (builder) => { ... });
// Async validation
addValidation(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() or makeAutoObservable(), call addValidation() after them to ensure observability is set up first.
  • Handlers run immediately by default on registration (initialRun: true). Set initialRun: false to wait for the first change.

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 delayMs option in handler options
  • Multiple rapid changes are batched and validated once after the delay
  • Handlers run immediately by default on registration unless initialRun: false is set
class FormModel {
@observable email = "";
@observable age = 0;
constructor() {
makeObservable(this);
addValidation(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 throttled
runInAction(() => {
form.email = "test";
});
runInAction(() => {
form.email = "invalid"; // Only validated once after delay
});
// Wait for validation to complete
await validator.waitForValidation();
validator.isValid // false
validator.invalidKeys // Set(["email"])
validator.invalidKeyCount // 1
// Get error messages
validator.getErrorMessages("email") // Set(["Invalid email format"])
validator.firstErrorMessage // "Invalid email format"
// Check for errors
validator.hasErrors("email") // true
validator.hasErrors("age") // false
// Get detailed errors
for (const [keyPath, error] of validator.findErrors("**")) {
console.log(`${keyPath}: ${error.message}`);
}

Access the current validation state through these reactive properties:

const validator = Validator.get(model);
// Validity state
validator.isValid // boolean - no validation errors
validator.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 errors
validator.invalidKeyPathCount // number - count of all errors
// Validation progress
validator.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 queries, by key path pattern (see below)
validator.firstErrorMessage // string | null - first error found
validator.getErrorMessages(...patterns) // Set<string> - messages of the matching key paths
validator.hasErrors(...patterns) // boolean - whether a matching key path has errors
validator.findErrors(...patterns) // Iterator<[KeyPath, ValidationError]>

Understanding validation states:

  • reactionState: Counts pending/running synchronous validation reactions
  • asyncState: Counts pending/running asynchronous validation jobs
  • isValidating: Convenience property that's true when either state is non-zero
  • Multiple handlers can add multiple errors to the same key - they accumulate in a Set

getErrorMessages(), hasErrors() and findErrors() take a key path pattern: keys separated by dots, as in a key path, where three spellings have a meaning of their own.

Pattern Matches
email email, and nothing else
. the object itself, where invalidateSelf() reports
items.* one key below items: items.0, items.1, and so on
items.*.name items.0.name, items.1.name, and so on
items.** items and every key path below it
** every key path, the object itself included

A pattern without wildcards matches only the key path it spells. For a key holding a nested object, that is what the model reports on the key and what the object reports on itself; its fields' errors take a wildcard:

validator.getErrorMessages("address") // errors of the address as a whole
validator.getErrorMessages("address.**") // those, and the errors of its fields

Pass several patterns to look up the key paths that match any of them. An error that several of them match is found once:

validator.getErrorMessages("city", "postalCode")
validator.hasErrors("address.**", "lineItems.**")

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 // false
validator.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
}