Core functionality for tracking changes to MobX observables and validating them.
This package provides three core primitives for reactive state management:
Track changes to observable properties in MobX models.
Watcher is designed to detect whether something has changed - useful for dirty state tracking in forms, triggering side effects when any field changes, detecting modifications for auto-save or data synchronization, and tracking change history. The changedKeys and changedKeyPaths properties are primarily for debugging purposes to identify which fields changed.
Use Watcher.get() to retrieve or create a watcher instance for an object:
const model = new MyModel();
const watcher = Watcher.get(model);
Watcher instances are cached and automatically garbage collected with their targets. The same object always returns the same watcher instance:
const watcher1 = Watcher.get(model);
const watcher2 = Watcher.get(model);
// watcher1 === watcher2 (same instance)
Use Watcher.getSafe() to get a watcher without throwing errors for non-objects:
const watcher = Watcher.getSafe(maybeObject);
// Returns null if maybeObject is not an object
Access the current change state through these reactive properties:
// Boolean indicating any changes
watcher.changed // boolean
// Set of changed property names (direct properties only)
// e.g., "name", "age"
watcher.changedKeys // Set<KeyPath>
// All changed paths including nested (when using @nested)
// "child.value", "items.0.name"
watcher.changedKeyPaths // Set<KeyPath>
// Counter that increments with each change (useful for reactions)
watcher.changedTick // bigint
Watching starts immediately when the Watcher instance is created.
The watcher begins tracking changes as soon as Watcher.get() is called for the first time.
To set a new starting point after initialization, use the reset() method. For fine-grained control over what gets tracked, see Temporarily Disable Tracking.
const model = new Model();
runInAction(() => {
model.name = "John"; // Not tracked: Watcher instance hasn't been created yet.
});
const watcher = Watcher.get(model); // Starts tracking from this point forward
watcher.changed // false
runInAction(() => {
model.name = "Not John"; // Tracked
});
watcher.changed // true
@observable and @computed are automatically tracked unless explicitly excluded with @unwatch.
@watch.ref for identity comparison only (reference equality)changedTick property uses bigint (starts at 0n) and can track unlimited changesclass Model {
@observable name = "";
@observable age = 0;
constructor() {
makeObservable(this);
}
@computed
get displayName() {
return `${this.name} (${this.age})`;
}
}
const model = new Model();
const watcher = Watcher.get(model);
runInAction(() => {
model.name = "John";
});
watcher.changed // true
watcher.changedKeys // Set(["name", "displayName"])
Assignments to the property are tracked, but changes to nested properties are NOT.
See also: Tracking Nested Objects.
class Model {
@observable user = { name: "John" };
constructor() {
makeObservable(this);
}
}
const model = new Model();
const watcher = Watcher.get(model);
runInAction(() => {
model.user.name = "Jane"; // Not tracked
});
watcher.changed // false
runInAction(() => {
model.user = { name: "Jane" }; // Tracked - assignment to property
});
watcher.changed // true
watcher.changedKeys // Set(["user"])
Mutations are tracked, but element changes are NOT.
See also: Tracking Nested Objects.
class Model {
@observable items = [{ value: 1 }];
@observable tags = new Set(["a"]);
@observable data = new Map([["key", { value: 1 }]]);
constructor() {
makeObservable(this);
}
}
const model = new Model();
const watcher = Watcher.get(model);
// Tracked: mutations to the collection itself
runInAction(() => {
model.items.push({ value: 2 });
model.tags.add("b");
model.data.set("key2", { value: 2 });
});
watcher.changedKeys // Set(["items", "tags", "data"])
watcher.reset();
// NOT tracked: changes to elements
runInAction(() => {
model.items[0].value = 99;
model.data.get("key")!.value = 99;
});
watcher.changed // false
Reset the watcher to clear all tracked changes:
const watcher = Watcher.get(model);
runInAction(() => {
model.name = "John";
});
watcher.changed // true
watcher.changedKeys // Set(["name"])
// Clear all tracked changes
watcher.reset();
watcher.changed // false
watcher.changedKeys // Set()
watcher.changedTick // 0n
Mark the watcher as changed without incrementing the tick (useful for external state synchronization):
watcher.assumeChanged();
watcher.changed // true
watcher.changedTick // 0n (not incremented)
The changedTick property is a counter that increments with each tracked change.
class Model {
@observable name = "";
@observable age = 0;
constructor() {
makeObservable(this);
}
@computed
get displayName() {
return `${this.name} (${this.age})`;
}
}
const model = new Model();
const watcher = Watcher.get(model);
watcher.changedTick // 0n
runInAction(() => {
model.name = "John";
});
// Each change increments the tick
watcher.changedTick // 2n (one for name, one for displayName)
watcher.changedKeys // Set(["name", "displayName"])
watcher.reset();
// After reset, both changedKeys and changedTick are cleared
watcher.changedKeys // Set()
watcher.changedTick // 0n
Most useful with reaction() to trigger side effects when changes occur:
const model = new Model();
const watcher = Watcher.get(model);
// React to any tracked changes
reaction(
() => watcher.changedTick,
() => {
// Process changes here...
saveToBackend(model);
}
);
runInAction(() => {
model.name = "John";
model.age = 30;
});
// Reaction triggers once after the transaction completes
Use unwatch() as a function to run code without changes being detected by Watcher.
const model = new Model();
const watcher = Watcher.get(model);
// Changes inside unwatch() are not tracked
unwatch(() => {
model.name = "John";
model.age = 30;
});
watcher.changed // false
// Normal changes are still tracked
runInAction(() => {
model.name = "Jane";
});
watcher.changed // true
⚠️ Warning about transactions: When used inside a transaction, watching only resumes when the outermost transaction completes. This is a fundamental limitation of the implementation.
runInAction(() => {
model.field1 = true; // Tracked: Before unwatch begins
unwatch(() => {
model.field2 = true; // Not tracked
});
model.field3 = true; // ⚠️ NOT tracked: Still in the same transaction as unwatch
});
// The transaction completes here; watching finally resumes
watcher.changedKeys // Set(["field1"])
@watchExplicitly mark properties for tracking (shallow comparison)
class Model {
@watch field = observable.box(false);
constructor() {
makeObservable(this);
}
}
@watch.refTrack with identity comparison only
class Model {
@watch.ref @observable items = [1, 2, 3];
constructor() {
makeObservable(this);
}
}
const model = new Model();
const watcher = Watcher.get(model);
runInAction(() => {
model.items.push(4); // No change detected (same array reference)
});
watcher.changed // false
@unwatchExclude properties from tracking
class Model {
@unwatch @observable internalState = false;
@observable userField = false;
@unwatch
@computed
get derivedState() {
return this.internalState ? "active" : "inactive";
}
constructor() {
makeObservable(this);
}
}
const model = new Model();
const watcher = Watcher.get(model);
runInAction(() => {
model.internalState = true; // Not tracked
});
watcher.changed // false
watcher.changedKeys // Set() - derivedState is also not tracked
Use @nested to track changes in nested object properties. Read Nested section for detail.
Watcher instance automaticallyreset() on a child watcher doesn't affect the parentclass Parent {
@nested @observable child = new Child();
@nested @observable items = [new Item()];
constructor() {
makeObservable(this);
}
}
class Child {
@observable value = false;
constructor() {
makeObservable(this);
}
}
const parent = new Parent();
const watcher = Watcher.get(parent);
runInAction(() => {
parent.child.value = true;
parent.items[0].value = true;
});
watcher.changed // true
watcher.changedKeys // Set([]) - no direct property changes
watcher.changedKeyPaths // Set(["child.value", "items.0.value"]) - nested change tracked
// Nested watchers are independent
const childWatcher = Watcher.get(parent.child);
childWatcher.reset(); // Does NOT affect parent watcher
watcher.changed // still true
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, debounced 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 and automatically garbage collected with their targets. The same object always returns the same validator instance:
const validator1 = Validator.get(model);
const validator2 = Validator.get(model);
// validator1 === validator2 (same instance)
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
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:
makeObservable() or makeAutoObservable(), call makeValidatable() after them to ensure observability is set up first.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:
Validator.defaultDelayMs (100ms)delayMs option in handler optionsinitialRun: false is setclass 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 throttled
runInAction(() => {
form.email = "test";
});
runInAction(() => {
form.email = "invalid"; // Only validated once after delay
});
// Wait for validation to complete
await when(() => !validator.isValidating);
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(KeyPath.Self)) {
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)
// Error queries
validator.firstErrorMessage // string | undefined - first error found
validator.getErrorMessages(keyPath) // Set<string> - errors for a path
validator.hasErrors(keyPath, deep?) // boolean - check for errors
validator.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's true when either state is non-zeroAsync validations are automatically debounced and cancellable.
Behaviors:
abortSignal parameter allows your handler to respond to cancellationfetch() and other async APIs to cancel in-flight requestsError handling:
class UserModel {
@observable username = "";
constructor() {
makeObservable(this);
makeValidatable(
this,
() => this.username,
async (username, builder, abortSignal) => {
// Automatic cancellation on new changes
const response = await fetch(`/api/check-username/${username}`, {
signal: abortSignal
});
if (!response.ok) {
builder.invalidate("username", "Username already taken");
}
},
{ initialRun: false }
);
}
}
const user = new UserModel();
const validator = Validator.get(user);
runInAction(() => {
user.username = "john";
});
// Previous job is cancelled
runInAction(() => {
user.username = "jane"; // Only this value will be validated
});
// Check validation state
validator.isValidating // true
validator.reactionState // 1 (sync validation pending)
validator.asyncState // 1 (async validation running)
await when(() => !validator.isValidating);
// The validation completed successfully
validator.isValid // true or false depending on the result
Parent validators automatically track child validation states.
class Parent {
@observable name = "";
@nested @observable child = new Child();
@nested @observable items = [new Child()];
constructor() {
makeObservable(this);
makeValidatable(this, (builder) => {
if (!this.name) {
builder.invalidate("name", "Name required");
}
});
}
}
class Child {
@observable email = "";
constructor() {
makeObservable(this);
makeValidatable(this, (builder) => {
if (!this.email.includes("@")) {
builder.invalidate("email", "Invalid email");
}
});
}
}
const parent = new Parent();
const validator = Validator.get(parent);
runInAction(() => {
parent.child.email = "invalid";
parent.items[0].email = "bad";
});
await when(() => !validator.isValidating);
validator.isValid // false - because nested errors exist
// Direct property errors
validator.invalidKeys // Set([]) - no direct errors
validator.invalidKeyCount // 0
// All errors including nested
validator.invalidKeyPaths // Set(["child.email", "items.0.email"])
validator.invalidKeyPathCount // 2
// Query nested errors
validator.hasErrors("child", true) // true (deep search)
validator.getErrorMessages("child.email") // Set(["Invalid email"])
// Get all nested errors
for (const [keyPath, error] of validator.findErrors(KeyPath.Self, true)) {
console.log(`${keyPath}: ${error.message}`);
}
// Output:
// child.email: Invalid email
// items.0.email: Invalid email
Validate the object itself rather than specific properties. Use self validation for cross-field validation (e.g., date ranges, password confirmation), business rules that involve multiple fields, or object-level constraints that don't belong to a single field.
class Model {
@observable startDate = new Date();
@observable endDate = new Date();
constructor() {
makeObservable(this);
makeValidatable(this, (builder) => {
if (this.startDate > this.endDate) {
builder.invalidateSelf("Start date must be before end date");
}
});
}
}
const model = new Model();
const validator = Validator.get(model);
runInAction(() => {
model.startDate = new Date("2024-12-31");
model.endDate = new Date("2024-01-01");
});
await when(() => !validator.isValidating);
// Self errors appear under KeyPath.Self
validator.getErrorMessages(KeyPath.Self) // Set(["Start date must be before end date"])
validator.hasErrors(KeyPath.Self) // true
Use updateErrors() to add errors outside of reactive validation handlers - such as displaying server-side validation errors after form submission, adding ad-hoc errors from external sources (e.g., API responses), implementing custom validation that doesn't fit the reactive model, or temporarily marking fields as invalid during multi-step workflows.
const validator = Validator.get(model);
const key = Symbol("custom-validation");
// Add errors manually
const dispose = validator.updateErrors(key, (builder) => {
builder.invalidate("field", "Custom error");
});
validator.hasErrors("field") // true
// Add errors manually - replaces previously added errors
const dispose = validator.updateErrors(key, (builder) => {
builder.invalidate("field2", "Custom error");
});
validator.hasErrors("field") // false
validator.hasErrors("field2") // true
// Remove errors when no longer needed
dispose();
validator.hasErrors("field") // false
validator.hasErrors("field2") // false
The key is an identifier that groups manual errors together, serving as a namespace to manage errors independently:
updateErrors() with the same key replaces previous errors from that key.Use different keys for different error sources (e.g., server validation, client validation, external APIs). The key is typically a Symbol to ensure uniqueness.
Example with multiple keys:
const serverKey = Symbol("server-errors");
const clientKey = Symbol("client-errors");
// Server validation errors
validator.updateErrors(serverKey, (builder) => {
builder.invalidate("email", "Email already exists");
});
// Client validation errors
validator.updateErrors(clientKey, (builder) => {
builder.invalidate("email", "Invalid format");
});
// Both errors coexist
validator.getErrorMessages("email") // Set(["Email already exists", "Invalid format"])
// Update server errors - only replaces serverKey's errors
validator.updateErrors(serverKey, (builder) => {
builder.invalidate("username", "Username taken");
});
validator.hasErrors("email") // still true (clientKey's error remains)
Utilities for working with nested observable structures.
The @nested annotation enables hierarchical tracking and validation - such as tracking changes in deeply nested form structures, validating parent and child objects together, aggregating errors from nested objects to parents, managing changes in arrays of objects, and working with maps, sets, and complex object graphs. Without @nested, Watcher and Validator only track direct property assignments, not changes within nested objects.
@nested AnnotationMarks properties as containing nested observable objects that should be tracked or validated.
@observable to make the property itself observable@nested alone is sufficient to track changes within the nested objectclass Model {
// Mutable properties - combine @nested with @observable
@nested @observable user = { name: "John" };
@nested profile = observable({ bio: "..." });
// Readonly properties - @nested alone is sufficient
@nested readonly settings = new Settings();
// Arrays
@nested @observable items = [{ id: 1 }];
// Sets
@nested @observable tags = new Set([{ name: "tag1" }]);
// Maps
@nested @observable data = new Map([["key", { value: 1 }]]);
// Boxed observables - automatically unwrapped
@nested current = observable.box({ active: true });
// Classes
@nested @observable child = new OtherModel();
constructor() {
makeObservable(this);
}
}
@nested.hoist AnnotationHoists nested changes to the parent level, removing the intermediate key from paths.
When to use @nested.hoist:
Restrictions:
@nested.hoist property per class@nested and @nested.hoist on the same propertyExample use case:
When you have a custom collection that wraps an internal data structure.
class UserCollection {
@nested.hoist private _items = observable.array<User>([]);
constructor() {
makeObservable(this);
}
get length() {
return this._items.length;
}
add(user: User) {
this._items.push(user);
}
get(index: number) {
return this._items[index];
}
}
class User {
@observable name = "";
@observable email = "";
constructor() {
makeObservable(this);
}
}
const collection = new UserCollection();
collection.add(new User());
const watcher = Watcher.get(collection);
runInAction(() => {
collection.get(0).name = "John";
});
// Without hoist: changedKeyPaths would be Set(["_items.0.name"])
// With hoist: changes are elevated to parent level
watcher.changedKeyPaths // Set(["0.name"])
A utility class for iterating over nested observable structures with custom data extraction.
When to use StandardNestedFetcher:
Key features:
@nested.hoist - hoisted entries use KeyPath.SelfdataMap is a computed property with structural equality (comparer.shallow)Important limitations:
dataMap uses structural equality, so changing object references will trigger updatesExample use case:
Build derived data structures from nested observables, like indexes or lookup tables.
class Parent {
@nested @observable items = [
new Item(1, "First"),
new Item(2, "Second")
];
constructor() {
makeObservable(this);
}
}
class Item {
@observable id: number;
@observable name: string;
constructor(id: number, name: string) {
this.id = id;
this.name = name;
makeObservable(this);
}
toString() {
return `Item(id = ${this.id}, name = ${this.name})`;
}
}
const parent = new Parent();
// Create a fetcher that builds a lookup table by ID
const fetcher = new StandardNestedFetcher(
parent,
(entry) => {
// entry.key: property name (e.g., "items")
// entry.keyPath: full path (e.g., "items.0", "items.1")
// entry.data: the nested object
// Transform the nested object - return null to exclude this entry
return entry.data instanceof Item ? entry.data.toString() : null;
}
);
// The fetcher is reactive.
// Changes to the nested structure (add/remove items) trigger re-computation
// Iterate over all nested items
for (const entry of fetcher) {
console.log(`${entry.keyPath}: ${entry.data}`);
}
// Output:
// items.0: Item(id = 1, name = First)
// items.1: Item(id = 2, name = Second)
// Get entries for a specific key
const itemEntries = fetcher.getForKey("items" as KeyPath);
for (const entry of itemEntries) {
console.log(entry.keyPath); // "items.0", "items.1", etc.
}
// Access as a computed map (reactive)
autorun(() => {
const dataMap = fetcher.dataMap;
// Map structure: KeyPath -> extracted data
const item0 = dataMap.get("items.0" as KeyPath);
const item1 = dataMap.get("items.1" as KeyPath);
// This autorun re-runs when items array changes (add/remove/reorder)
// It does NOT re-run when individual item properties change
console.log(`Total items: ${dataMap.size}`);
});
runInAction(() => {
parent.items.push(new Item(3, "Third"));
// autorun triggers because the array structure changed
});
runInAction(() => {
parent.items[0].name = "Updated";
// autorun does NOT trigger - only the item changed, not the structure
});
Performance note: Because dataMap uses comparer.shallow for structural equality, the computed property only recalculates when the map's structure changes (keys added/removed), not when individual values change. This is efficient for large nested structures.
This library supports both stage-2 and stage-3 decorators.
"experimentalDecorators": trueYou can use either decorator version depending on your TypeScript configuration. All decorators in this library work with both standards.