Skip to content

Watcher

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. The same object always returns the same watcher instance:

const watcher1 = Watcher.get(model);
const watcher2 = Watcher.get(model);
// watcher1 === watcher2 (same instance)

A watcher is garbage collected together with its target, even when the target references state that outlives it, such as a @nested object or an observable array, set or map that other objects share. The exception is a @computed of the target that the watcher observes, such as a @watch @computed, while it reads such state, like a store: as long as the watcher observes the computed, the computed observes the store, and a computed references the object it belongs to, so the store keeps the target alive. MobX alone would let the target go, as a computed that nothing observes does not observe what it reads. A watcher cannot be disposed; to let such a target go, take @watch off, which also stops tracking the property's changes.

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, including the changes made after it in the same transaction.

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 is automatically tracked unless explicitly excluded with @unwatch. @computed is not: see Computed properties.

  • Properties are tracked using shallow comparison by default (arrays/sets/maps are compared by creating shallow copies)
  • Use @watch.ref for identity comparison only (reference equality)
  • The changedTick property uses bigint (starts at 0n) and can track unlimited changes
class Model {
@observable name = "";
@observable age = 0;
constructor() {
makeObservable(this);
}
}
const model = new Model();
const watcher = Watcher.get(model);
runInAction(() => {
model.name = "John";
});
watcher.changed // true
watcher.changedKeys // Set(["name"])

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

@computed is not tracked unless you add @watch (or @watch.ref). A computed value derives from other state, and a change to that state is detected where the state is tracked. Tracking the computed too would report the same edit twice, and would report changes to state the model doesn't own, like a store the computed reads.

class Model {
@observable name = "";
@observable age = 0;
constructor() {
makeObservable(this);
}
@computed
get displayName() {
return `${this.name} (${this.age})`;
}
@watch
@computed
get isAdult() {
return this.age >= 18;
}
}
const model = new Model();
const watcher = Watcher.get(model);
runInAction(() => {
model.name = "John";
model.age = 20;
});
watcher.changedKeys // Set(["name", "age", "isAdult"]) - displayName is not tracked

So excluding a property with @unwatch excludes the computed values derived from it too, unless they are tracked with @watch. See @unwatch.