trakked
v3.0.1
Published
Operation tracking, undo/redo, dirty state, and validation for TypeScript
Maintainers
Readme
Trakked
A TypeScript library for frontend state management — undo/redo, dirty tracking, validation, composable edits, and server-assigned ID handling.
Built on the TC39 decorator standard (Stage 3). Requires TypeScript 5+ with experimentalDecorators not set.
Installation
npm install trakked// tsconfig.json — no experimentalDecorators needed
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"]
}
}Quick Start
import {
Tracker,
TrackedObject,
Tracked,
TrackedCollection,
} from 'trakked';
const tracker = new Tracker();
class InvoiceModel extends TrackedObject {
@Tracked()
accessor status: string = '';
@Tracked((self, value) => !value ? 'Status is required' : undefined)
accessor total: number = 0;
readonly lines: TrackedCollection<string>;
constructor(tracker: Tracker) {
super(tracker);
this.lines = new TrackedCollection(tracker);
}
}
const invoice = tracker.construct(() => new InvoiceModel(tracker));
invoice.status = 'draft'; // recorded
invoice.total = 100; // recorded
invoice.lines.push('item-1'); // recorded
tracker.isDirty; // true
tracker.canUndo; // true
tracker.undo(); // reverts lines.push
tracker.undo(); // reverts total
tracker.undo(); // reverts status
tracker.isDirty; // falseConcepts
Undo/redo strategy
The two common patterns for implementing undo/redo are:
- Command — every change stores a
redoActionand anundoActionclosure pair. Undoing calls the inverse function; redoing calls the original. No state is copied. - Memento — the entire state (or a relevant slice) is snapshotted before each change and restored on undo. Simpler to implement because no inverse logic is required, but carries memory and copying overhead on every change.
Trakked uses the Command pattern because, once correctly implemented, it is strictly more efficient: no memory overhead, no copying, and undo granularity is exactly as fine or coarse as designed.
How undo steps are created
Every tracked write — a @Tracked() property assignment or a TrackedCollection mutation — becomes its own undo step unless it fires as a synchronous side-effect of another tracked write that is already in progress.
invoice.status = 'void' → undo step A
invoice.lines.clear() → undo step B (independent)If a TrackedCollection.changed listener updates a @Tracked() property synchronously, both the collection mutation and the property update land in the same undo step:
order.items.push('x') → undo step A
└─ changed listener: order.itemCount = 1 (nested, same step A)
tracker.undo() → items back to [], itemCount back to 0This nesting is detected automatically. No extra API is needed.
String and number aggregation
Rapid consecutive writes to the same string or number property on the same model can be merged into a single undo step. Coalescing is opt-in per property via the coalesceWithin option on @Tracked(). Pass the maximum gap in milliseconds between two writes that should still be considered part of the same edit:
@Tracked(undefined, { coalesceWithin: 3000 })
accessor status: string = '';invoice.status = 'd';
invoice.status = 'dr';
invoice.status = 'dra';
invoice.status = 'draft';
tracker.undo(); // reverts all four at once → status = ''Properties without coalesceWithin — and all Date, boolean, and object properties — are never coalesced; every write produces its own undo step.
Dependency tracking
Validators can read other properties of the same model — for example, a scheduleDays field might be required only when isEnabled is true. Trakked automatically tracks which properties each validator reads, and re-runs only the affected validators when those properties change.
This works via a lightweight dependency tracking mechanism built into the @Tracked getter. Every time a validator runs, Trakked collects every @Tracked property that is read during the call. These are recorded as dependencies. When any of those properties is written next, only the validators that declared a dependency on it are re-evaluated — not the entire model.
Consequence for get/set pairs: The dependency is registered through the getter, not the setter. If a property is written via a plain setter and its getter is not decorated with @Tracked, any validator that reads it will not discover the dependency — and will not re-run when the property changes.
// WRONG — isEnabled getter is plain; validators that read self.isEnabled
// will not re-run when isEnabled changes
get isEnabled(): boolean { return this._isEnabled; }
@Tracked()
set isEnabled(value: boolean) { this._isEnabled = value; }// CORRECT — both getter and setter are decorated
@Tracked()
get isEnabled(): boolean { return this._isEnabled; }
@Tracked()
set isEnabled(value: boolean) { this._isEnabled = value; }When using accessor fields this is never an issue — the getter and setter share the same decoration.
Construction via tracker.construct()
All tracked model objects must be created inside tracker.construct(). This call:
- Suppresses tracking for the entire constructor body — property writes during construction are silently applied without creating undo entries
- Validates the object once after construction
- Triggers a tracker-wide
revalidate()to synctracker.isValid
The tracker is clean and canUndo is false immediately after tracker.construct() returns.
Single object:
const invoice = tracker.construct(() => new InvoiceModel(tracker));Multiple objects at once:
tracker.construct(() => {
new OrderModel(tracker);
new OrderLine(tracker);
});Development vs production builds
Trakked ships two builds: a development build (dist/dev/) and a production build (dist/prod/).
Development build — creating a tracked object outside tracker.construct() throws immediately with a descriptive error:
MyModel must be created inside tracker.construct()This catches accidental bare new MyModel(tracker) calls at the earliest possible moment during development.
Production build — the construction guard is compiled away entirely. There is zero runtime overhead for the check.
Build selection is automatic. Bundlers that support the exports field in package.json — Vite, webpack 5+, and others — pick the development build when building in development mode and the production build when building for production. Nothing extra is required from consumers; the correct build is selected via the development export condition in Trakked's package.json.
Bulk construction
tracker.construct() is the canonical way to create any number of objects — single or many. When constructing multiple objects, pass them all inside a single tracker.construct() callback. Trakked suppresses tracking for the entire block and calls tracker.revalidate() exactly once after all objects are constructed, keeping bulk creation O(n):
tracker.construct(() => {
for (const row of serverRows) {
const item = new ItemModel(tracker);
item.name = row.name;
}
});
// tracker.revalidate() is called once here — not once per objectDefault state: Unchanged
Both TrackedObject and VersionedTrackedObject default to Unchanged at construction time. This matches the most common scenario — objects are loaded from the database and are already persisted.
const item = tracker.construct(() => new ItemModel(tracker)); // state: Unchanged (DB-loaded default)To create a new item that needs to be inserted, add it to a TrackedCollection via push. The collection is responsible for transitioning the object to New:
const item = tracker.construct(() => new ItemModel(tracker));
items.push(item); // state: New — tracked, undoable
tracker.undo(); // state: Unchanged, removed from collectionItems passed to the TrackedCollection constructor are treated as already-persisted rows and are not marked as New:
const items = new TrackedCollection<ItemModel>(tracker, [dbItem]); // dbItem stays UnchangedWhen you need a New object outside of a collection, pass the initial state explicitly:
const item = tracker.construct(() => new ItemModel(tracker, ItemState.New));API Reference
Tracker
The central coordinator. Create one per page or form context and pass it to every model and collection.
const tracker = new Tracker();State properties
| Property | Type | Description |
|---|---|---|
| isDirty | boolean | true when uncommitted changes exist |
| canUndo | boolean | true when there is at least one undo step |
| canRedo | boolean | true when there are undone steps to redo |
| isValid | boolean | true when every registered model and collection passes validation |
| canCommit | boolean | true when isDirty && isValid — ready to submit to the server |
| isDirtyChanged | TypedEvent<boolean> | Fires whenever isDirty changes |
| isValidChanged | TypedEvent<boolean> | Fires whenever isValid changes |
| canCommitChanged | TypedEvent<boolean> | Fires whenever canCommit changes |
| version | number | Monotonically changing counter — starts at 0, increments on every new operation, decrements on undo, increments on redo. Auto-coalesced writes do not increment version (no new undo step is created) but still emit versionChanged |
| versionChanged | TypedEvent<number> | Fires on every tracked write, undo, and redo — including auto-coalesced writes where version does not change. Use this as the notification signal for external subscribers such as React's useSyncExternalStore |
| trackedObjects | TrackedObjectBase[] | All registered models |
| trackedCollections | TrackedCollection<any>[] | All registered collections |
Undo / redo
tracker.undo(); // reverts the last undo step
tracker.redo(); // re-applies the last undone stepCalling undo() or redo() when the respective flag is false is a no-op.
Commit lifecycle
tracker.onCommit(); // mark current state as committed — isDirty → false
tracker.onCommit(keys); // same, plus swap placeholder IDs for real server IDs
tracker.beforeCommit(); // assign temporary negative IDs to new models before committingonCommit() automatically transitions every tracked object's state to Unchanged and appends the state change into the existing last undo operation — so undo atomically reverts both the user's edits and the committed state together (no spurious extra undo steps).
Composing
tracker.startCoalescing(); // begin grouping subsequent changes
tracker.endCoalescing(); // commit — all changes become one undo step
tracker.rollbackCoalescing(); // revert — all changes since startCoalescing are rolled backObject construction
// Single object — returns the constructed instance
const model = tracker.construct(() => new MyModel(tracker));
// Multiple objects — returns void
tracker.construct(() => {
new ModelA(tracker);
new ModelB(tracker);
});tracker.construct() suppresses tracking for the entire callback, runs validators once after all objects are created, and calls tracker.revalidate() exactly once at the end.
Tracking suppression
// Callback form — preferred
tracker.withTrackingSuppressed(() => {
model.field = 'silent'; // applied but not recorded, not dirty
});
// Explicit begin/end — useful when the suppressed block spans async boundaries
tracker.beginSuppressTracking();
model.field = 'silent';
tracker.endSuppressTracking();Suppression is nestable via a counter, so calling beginSuppressTracking() twice requires two endSuppressTracking() calls to resume tracking.
Composing
Groups all tracked changes made between startCoalescing() and endCoalescing() into a single undo step. Call rollbackCoalescing() instead to revert all changes made during the composing session.
tracker.startCoalescing();
model.firstName = 'Alice';
model.lastName = 'Smith';
model.email = '[email protected]';
// Keep changes — all three writes become one undo step
tracker.endCoalescing();
tracker.undo(); // reverts firstName, lastName, and email togethertracker.startCoalescing();
model.firstName = 'Alice';
model.lastName = 'Smith';
// Discard changes — all writes since startCoalescing are reverted
tracker.rollbackCoalescing();
// model.firstName and model.lastName are back to their previous valuesNesting is not supported: a second call to startCoalescing() while composing is already active is a no-op.
Typical use case — edit modal
Open a modal that edits a slice of the model. If the user confirms, the entire set of modal edits lands in the undo history as one step. If the user cancels, all edits are rolled back invisibly.
function openEditModal(model: PersonModel) {
tracker.startCoalescing();
showModal({
model,
onConfirm: () => tracker.endCoalescing(),
onCancel: () => tracker.rollbackCoalescing(),
});
}React integration — useSyncExternalStore
version and versionChanged are designed to plug directly into React's useSyncExternalStore. Subscribe to versionChanged as the store and snapshot tracker.version — any component that calls the hook will automatically re-render on every tracked mutation, undo, or redo with no bridging code required:
import { useSyncExternalStore } from 'react';
import { Tracker } from 'trakked';
function useTrackerVersion(tracker: Tracker): number {
return useSyncExternalStore(
(onStoreChange) => tracker.versionChanged.subscribe(onStoreChange),
() => tracker.version,
);
}Any component that calls useTrackerVersion(tracker) will re-render whenever the tracker's state changes.
function InvoiceForm({ tracker, invoice }: { tracker: Tracker; invoice: InvoiceModel }) {
useTrackerVersion(tracker); // re-renders on every mutation, undo, or redo
return (
<form>
<input value={invoice.status} onChange={(e) => { invoice.status = e.target.value; }} />
<button disabled={!tracker.canUndo} onClick={() => tracker.undo()}>Undo</button>
<button disabled={!tracker.canCommit} onClick={save}>Save</button>
</form>
);
}TrackedObject
TrackedObject is the abstract base class for all trackable models in non-versioned (standard CRUD) databases. For versioned (temporal) databases see VersionedTrackedObject below.
All subclass instances must be created via tracker.construct().
class InvoiceModel extends TrackedObject {
constructor(tracker: Tracker) {
super(tracker); // registers the model with the tracker
}
}
const invoice = tracker.construct(() => new InvoiceModel(tracker));Model properties and methods
| Member | Type | Description |
|---|---|---|
| tracker | Tracker | The tracker this model belongs to (set via super(tracker)) |
| isDirty | boolean | true when this model has uncommitted changes |
| dirtyCounter | number | Net number of tracked changes since last save. Increments on every tracked write, decrements on undo |
| isValid | boolean | true when all @Tracked() validators pass |
| validationMessages | Map<string, string> | Maps property name → error message for each failing validator |
| state | ObjectState | Computed DB operation required at save time |
| _committedState | ObjectState | The persisted state. Defaults to Unchanged. Pass initialState to the constructor to override |
| destroy() | void | Removes this model from the tracker |
| onCommitted() | void | Called automatically by tracker.onCommit() — transitions _committedState and records the inverse in the undo stack so that undoing a commit restores the correct state |
ObjectState
Used by TrackedObject for non-versioned CRUD databases. Read via obj.state.
import { ObjectState } from 'trakked';| Value | Meaning | Required DB operation |
|---|---|---|
| New | Created by user, never saved | INSERT |
| Unchanged | Loaded from DB or just saved — no pending action | — |
| Edited | Unchanged + unsaved property changes (derived) | UPDATE |
| Deleted | Removed from a TrackedCollection | DELETE |
Edited is derived: when _committedState === Unchanged and the object has unsaved property changes (isDirty === true), state returns Edited. It is never stored directly.
Undo of a committed save:
tracker.onCommit() records the state transition in the undo stack. Undoing past a commit reverses the server operation:
| Committed operation | State after undo | Required server operation |
|---|---|---|
| INSERT (New) | Deleted | DELETE |
| UPDATE (Edited) | Edited (with pre-edit values) | UPDATE |
| DELETE (Deleted) | New | INSERT |
Loading from DB:
Objects default to Unchanged, so no extra setup is needed. Property values set inside the constructor are suppressed by tracker.construct():
class InvoiceModel extends TrackedObject {
@Tracked() accessor status: string = '';
constructor(tracker: Tracker, data?: { status: string }) {
super(tracker); // initialState defaults to Unchanged
if (data) this.status = data.status; // suppressed — not tracked
}
}
const invoice = tracker.construct(() => new InvoiceModel(tracker, { status: 'active' })); // state: UnchangedSaving:
for (const obj of tracker.trackedObjects) {
if (!(obj instanceof InvoiceModel)) continue;
switch (obj.state) {
case ObjectState.New: /* INSERT */ break;
case ObjectState.Edited: /* UPDATE */ break;
case ObjectState.Deleted: /* DELETE */ break;
case ObjectState.Unchanged: break;
}
}
await saveToServer();
tracker.onCommit(); // all objects → Unchanged; isDirty → falseVersionedTrackedObject
Use this instead of TrackedObject when your database is versioned (temporal) — records are never modified in-place; edits close the current row and insert a new version, and deletes are soft.
VersionedTrackedObject is also the right choice even for standard CRUD databases if you need the *Reverted states — i.e., your app must react when the user undoes a previously committed save.
import {
VersionedTrackedObject,
VersionedObjectState,
AutoId,
Tracked,
} from 'trakked';
class OrderModel extends VersionedTrackedObject {
@AutoId
id: number = 0;
@Tracked()
accessor description: string = '';
constructor(tracker: Tracker) {
super(tracker);
}
}
const order = tracker.construct(() => new OrderModel(tracker));Additional members (on top of TrackedObject's API)
| Member | Type | Description |
|---|---|---|
| state | VersionedObjectState | 7-state version of ObjectState (see below) |
| _committedState | VersionedObjectState | The persisted state |
| pendingHardDeletes | Set<number> | Real DB ids that must be hard-deleted on the server before the next insert of this object |
VersionedObjectState
import { VersionedObjectState } from 'trakked';| Value | Meaning | Required DB operation |
|---|---|---|
| New | Created by user, never saved | INSERT |
| Unchanged | Loaded from DB or just saved — no pending action | — |
| Edited | Unchanged + unsaved property changes (derived) | Close current row + INSERT new version |
| Deleted | Removed from a TrackedCollection | SOFT DELETE |
| InsertReverted | A saved insert was undone | HARD DELETE the inserted row |
| EditReverted | A saved edit was undone | HARD DELETE new version + REOPEN previous version |
| DeleteReverted | A saved delete was undone | REOPEN (clear end date / restore) |
Edited is derived, exactly as in ObjectState.
The three *Reverted states arise when the user undoes a tracker.onCommit() call. Each encodes the fact that a row now exists in the database that the user has logically rolled back, requiring an explicit compensating write on the server.
Loading from DB:
Objects default to Unchanged. Set properties inside the constructor — they are suppressed by tracker.construct():
class OrderModel extends VersionedTrackedObject {
@AutoId id: number = 0;
@Tracked() accessor description: string = '';
constructor(tracker: Tracker, data?: { id: number; description: string }) {
super(tracker); // initialState defaults to Unchanged
if (data) {
this.id = data.id;
this.description = data.description;
}
}
}
const order = tracker.construct(() => new OrderModel(tracker, { id: 42, description: 'Widget' })); // state: UnchangedCreating a new item:
const item = tracker.construct(() => new OrderModel(tracker)); // state: Unchanged
collection.push(item); // state: New — collection sets itVersioned save lifecycle
This is the complete pattern a client should follow when saving with VersionedTrackedObject. Three concerns must be handled: deciding what DB operations each object needs, managing placeholder IDs for new rows, and issuing hard deletes when an insert is undone.
Step 1 — read pending operations
Before sending anything to the server, iterate tracker.trackedObjects and read state and pendingHardDeletes on each VersionedTrackedObject:
import { VersionedTrackedObject, VersionedObjectState } from 'trakked';
interface SavePayload {
inserts: { placeholder: number; data: unknown }[];
updates: { id: number; data: unknown }[];
softDeletes: { id: number }[];
hardDeletes: { id: number }[];
reopens: { id: number }[];
}
function buildPayload(tracker: Tracker): SavePayload {
const payload: SavePayload = {
inserts: [], updates: [], softDeletes: [],
hardDeletes: [], reopens: [],
};
// Assign placeholder IDs to all objects that need a new DB row
tracker.beforeCommit();
for (const obj of tracker.trackedObjects) {
if (!(obj instanceof VersionedTrackedObject)) continue;
// Hard deletes that must reach the server before the new insert
for (const id of obj.pendingHardDeletes) {
payload.hardDeletes.push({ id });
}
switch (obj.state) {
case VersionedObjectState.New:
// id is a negative placeholder assigned by beforeCommit()
payload.inserts.push({ placeholder: obj.id, data: serialize(obj) });
break;
case VersionedObjectState.Edited:
// Close current DB row + insert new version
payload.softDeletes.push({ id: obj.id });
payload.inserts.push({ placeholder: obj.id, data: serialize(obj) });
break;
case VersionedObjectState.Deleted:
payload.softDeletes.push({ id: obj.id });
break;
case VersionedObjectState.InsertReverted:
// pendingHardDeletes already added above; optionally re-insert
payload.inserts.push({ placeholder: obj.id, data: serialize(obj) });
break;
case VersionedObjectState.EditReverted:
// Hard delete new row + reopen the previous row
// pendingHardDeletes already added above
payload.reopens.push({ id: obj.previousId }); // your domain logic
break;
case VersionedObjectState.DeleteReverted:
payload.reopens.push({ id: obj.id });
break;
case VersionedObjectState.Unchanged:
break;
}
}
return payload;
}Order matters: hard deletes in
pendingHardDeletesmust be sent to the server before (or in the same transaction as) the new insert for the same object, because the previous DB row for that id must not conflict with the incoming insert.
Step 2 — send to server and receive real IDs
const payload = buildPayload(tracker);
const response = await api.save(payload);
// response.ids: Array<{ placeholder: number; value: number }>Step 3 — apply real IDs and mark clean
tracker.onCommit(response.ids);
// Every VersionedTrackedObject → state Unchanged
// Placeholder IDs replaced with real DB ids
// tracker.isDirty === falseAfter a successful onCommit, clear pendingHardDeletes on each object to avoid re-sending them on the next cycle:
for (const obj of tracker.trackedObjects) {
if (obj instanceof VersionedTrackedObject) {
obj.pendingHardDeletes.clear();
}
}Step 4 — handling rollback
If the server returns an error, do not call tracker.onCommit(). The tracker remains dirty, state values are unchanged, and the user can continue editing or retry.
Complete versioned save example
import {
Tracker,
VersionedTrackedObject,
VersionedObjectState,
Tracked,
AutoId,
TrackedCollection,
} from 'trakked';
const tracker = new Tracker();
class OrderLine extends VersionedTrackedObject {
@AutoId
id: number = 0;
@Tracked((_, v) => !v ? 'Description is required' : undefined)
accessor description: string = '';
@Tracked((_, v) => v <= 0 ? 'Quantity must be positive' : undefined)
accessor quantity: number = 1;
constructor(tracker: Tracker) {
super(tracker);
}
}
class OrderModel extends VersionedTrackedObject {
@AutoId
id: number = 0;
@Tracked((_, v) => !v ? 'Status is required' : undefined)
accessor status: string = '';
readonly lines: TrackedCollection<OrderLine>;
constructor(tracker: Tracker) {
super(tracker);
this.lines = new TrackedCollection<OrderLine>(
tracker,
[],
(list) => list.length === 0 ? 'At least one line is required' : undefined,
);
}
}
// ---- Create and edit ----
const { order, line1 } = tracker.construct(() => ({
order: new OrderModel(tracker),
line1: new OrderLine(tracker),
}));
order.status = 'draft';
line1.description = 'Widget';
line1.quantity = 3;
order.lines.push(line1);
// ---- Save (insert) ----
tracker.beforeCommit();
// order.id === -1, line1.id === -2
const response1 = await api.save({
inserts: [
{ placeholder: order.id, data: { status: order.status } },
{ placeholder: line1.id, data: { description: line1.description, quantity: line1.quantity } },
],
});
// response1.ids: [{ placeholder: -1, value: 10 }, { placeholder: -2, value: 20 }]
tracker.onCommit(response1.ids);
// order.id === 10, line1.id === 20, state === Unchanged
// ---- User edits and saves again ----
order.status = 'confirmed';
tracker.beforeCommit();
// order.id is already positive — untouched by beforeCommit
const response2 = await api.save({
// Close row 10, open new version
softDeletes: [{ id: order.id }],
inserts: [{ placeholder: order.id, data: { status: order.status } }],
});
tracker.onCommit(response2.ids);
// ---- User undoes the second save ----
tracker.undo();
// order.state === EditReverted
// order.pendingHardDeletes contains the id of the new version that must be hard-deleted
// ---- Re-save from EditReverted ----
tracker.beforeCommit(); // reassigns a fresh placeholder (current id is negative placeholder)
const toHardDelete = [...order.pendingHardDeletes]; // ids to remove from DB
const response3 = await api.save({
hardDeletes: toHardDelete.map(id => ({ id })),
reopens: [{ id: 10 }], // reopen the previous version
});
tracker.onCommit(response3.ids);
// Clear pendingHardDeletes now that the server has processed them
for (const obj of tracker.trackedObjects) {
if (obj instanceof VersionedTrackedObject) {
obj.pendingHardDeletes.clear();
}
}@AutoId
Marks a property as the server-assigned autoincrement primary key for this model. Works with both TrackedObject and VersionedTrackedObject. Only one @AutoId field is allowed per class. Enables the beforeCommit / onCommit lifecycle for placeholder ID management.
class InvoiceModel extends TrackedObject {
@AutoId
id: number = 0;
@Tracked()
accessor status: string = '';
constructor(tracker: Tracker) {
super(tracker);
}
}Typical save flow:
const invoice = tracker.construct(() => new InvoiceModel(tracker));
invoice.status = 'draft';
// 1. Just before sending to the server:
tracker.beforeCommit();
// invoice.id is now -1 (a temporary placeholder)
// Multiple new models get -1, -2, -3, ...
// 2. Send to server, receive real IDs back:
const serverIds = [{ placeholder: invoice.id, value: 42 }];
// 3. Apply real IDs and mark clean:
tracker.onCommit(serverIds);
// invoice.id is now 42
// tracker.isDirty is falsebeforeCommit() only assigns a placeholder if the property's current value is ≤ 0. Models that already have a positive ID are left untouched.
onCommit() with no arguments (or an empty array) still marks the tracker as clean — it just skips the ID replacement step.
The placeholder counter never resets — each cycle continues from where it left off — so placeholder IDs are globally unique across the lifetime of the tracker and can never collide across save cycles.
Undo restores the placeholder, not zero. When the user undoes an onCommit(), the ID reverts to the negative placeholder that was active at save time (not 0). This means beforeCommit() on the next cycle sees id < 0 and correctly assigns a fresh unique placeholder.
@Tracked()
The property decorator. Intercepts every write, records an undo/redo pair, and optionally validates the new value. Works with accessor fields, explicit get/set pairs, and plain getters. Place it on the accessor, the setter, or the getter.
With accessor (recommended):
class ProductModel extends TrackedObject {
@Tracked()
accessor name: string = '';
@Tracked()
accessor price: number = 0;
@Tracked()
accessor active: boolean = true;
@Tracked()
accessor config: Record<string, unknown> = {};
@Tracked()
accessor createdAt: Date = new Date();
constructor(tracker: Tracker) {
super(tracker);
}
}With get/set — decorate the setter:
class ProductModel extends TrackedObject {
private _name: string = '';
get name(): string { return this._name; }
@Tracked()
set name(value: string) { this._name = value; }
constructor(tracker: Tracker) {
super(tracker);
}
}With get/set and side effects — decorate both getter and setter:
When the setter contains side-effect logic that must stay intact (e.g. cascading writes to other properties), decorate both the getter and the setter. The getter decoration registers isEnabled as a dependency source — any validator that reads it will automatically re-run when the setter fires. The setter decoration handles undo/redo as usual.
class RuleModel extends TrackedObject {
private _isEnabled: boolean = false;
@Tracked()
get isEnabled(): boolean { return this._isEnabled; }
@Tracked()
set isEnabled(value: boolean) {
this._isEnabled = value;
if (value) {
this.scheduleDays = 'mon';
} else {
this.scheduleDays = '';
}
}
@Tracked((self: RuleModel, v) =>
self.isEnabled && !v ? 'Day is required' : undefined
)
accessor scheduleDays: string = '';
constructor(tracker: Tracker) {
super(tracker);
}
}When isEnabled is set to true, scheduleDays's validator automatically re-runs because the getter declared the dependency. No manual revalidate() call is needed.
Note: decorating just the getter (without the setter) is valid when the getter is purely computed — it registers the property as a dependency source without attaching any undo/redo logic.
With a validator:
The validator receives the model instance and the incoming value. Return an error string to fail, undefined to pass.
class OrderModel extends TrackedObject {
@Tracked((self, value) => !value ? 'Status is required' : undefined)
accessor status: string = '';
@Tracked((self, value) => value < 0 ? 'Price must be positive' : undefined)
accessor price: number = 0;
// Validator can inspect other properties of the model
@Tracked((self: OrderModel, value) =>
value > self.price ? 'Discount exceeds price' : undefined
)
accessor discount: number = 0;
constructor(tracker: Tracker) {
super(tracker);
}
}Validators are re-evaluated after every tracked write and after every undo/redo. Results are stored in model.validationMessages and rolled up into tracker.isValid.
Validators that read other properties automatically re-run when those properties change — this is handled by the dependency tracking mechanism (see Dependency tracking in Concepts). For this to work, every property read inside a validator must be exposed through a @Tracked-decorated getter. accessor fields satisfy this automatically. For get/set pairs, both the getter and setter must be decorated with @Tracked — see the "getter + setter with side effects" example above.
No-op detection
Assigning the same value twice does not create an undo step and does not mark the model dirty. null and undefined are treated as equivalent to '' for string properties.
invoice.status = ''; // no-op (already '')
invoice.status = null; // no-op (null ≡ '')
invoice.status = 'draft'; // recorded
invoice.status = 'draft'; // no-opOptions
An optional second argument controls decorator behaviour:
@Tracked(validator?, options?)| Option | Type | Default | Description |
|---|---|---|---|
| coalesceWithin | number | undefined | Maximum gap in ms between two consecutive writes to this property that should be merged into one undo step. Omit to never coalesce |
// Validator + coalesceWithin together:
@Tracked((_, v) => v < 0 ? 'Must be positive' : undefined, { coalesceWithin: 3000 })
accessor quantity: number = 0;
// coalesceWithin only (no validator):
@Tracked(undefined, { coalesceWithin: 3000 })
accessor status: string = '';Supported property types: string, number, boolean, Date, object. Unsupported types throw at runtime.
TrackedCollection<T>
A fully array-compatible tracked collection. All mutations are recorded and undoable. Implements Array<T> so it works anywhere an array is expected.
const items = new TrackedCollection<string>(tracker);
// With initial items:
const items = new TrackedCollection<string>(tracker, ['a', 'b']);
// With a validator:
const items = new TrackedCollection<string>(
tracker,
[],
(list) => list.length === 0 ? 'At least one item is required' : undefined,
);Tracked mutation methods
All of these create undo steps:
| Method | Description |
|---|---|
| push(...items) | Appends one or more items |
| pop() | Removes and returns the last item |
| shift() | Removes and returns the first item |
| unshift(...items) | Prepends one or more items |
| splice(start, deleteCount, ...items) | Low-level insert/remove at a position |
| remove(item) | Removes a specific item by reference. Returns false if not found |
| replace(item, replacement) | Replaces a specific item by reference. Returns false if not found |
| replaceAt(index, replacement) | Replaces the item at a given index |
| clear() | Removes all items |
| reset(newItems) | Replaces the entire collection with a new array |
| fill(value, start?, end?) | Fills a range with a value |
| copyWithin(target, start, end?) | Copies a slice to another position |
Read-only / non-mutating methods
indexOf, lastIndexOf, includes, find, findIndex, findLast, findLastIndex, every, some, forEach, map, filter, flatMap, reduce, reduceRight, concat, join, slice, at, entries, keys, values, flat, reverse, sort, toReversed, toSorted, toSpliced, with, toString, toLocaleString
Additional properties
| Member | Description |
|---|---|
| length | Number of items |
| isDirty | true when the collection has unsaved mutations |
| isValid | true when the validator passes (or no validator was provided) |
| error | The current validation error message, or undefined |
| changed | TypedEvent<TrackedCollectionChanged<T>> — fires after every mutation |
| first() | Returns the first item, or undefined if empty |
| destroy() | Removes the collection from the tracker |
The changed event
TrackedCollectionChanged<T> carries:
| Property | Description |
|---|---|
| added | Items that were inserted |
| removed | Items that were removed |
| newCollection | The full collection after the mutation |
items.changed.subscribe((e) => {
console.log('added:', e.added);
console.log('removed:', e.removed);
console.log('now:', e.newCollection);
});The changed event fires outside tracking suppression. This means a listener that writes to a @Tracked() property composes naturally with the collection mutation — both land in the same undo step:
class OrderModel extends TrackedObject {
@Tracked()
accessor itemCount: number = 0;
readonly items: TrackedCollection<string>;
constructor(tracker: Tracker) {
super(tracker);
this.items = new TrackedCollection(tracker);
this.items.changed.subscribe(() => {
this.itemCount = this.items.length; // composed into the same undo step
});
}
}
const order = tracker.construct(() => new OrderModel(tracker));
order.items.push('x'); // itemCount becomes 1
tracker.undo(); // items back to [], itemCount back to 0TypedEvent<T>
A lightweight, strongly-typed event emitter. Used internally for tracker.isDirtyChanged, tracker.isValidChanged, and TrackedCollection.changed, and available for your own use.
const event = new TypedEvent<string>();
// subscribe returns an unsubscribe function
const unsubscribe = event.subscribe((value) => {
console.log('received:', value);
});
event.emit('hello'); // → "received: hello"
unsubscribe(); // stop listening
event.emit('world'); // → (nothing)| Method | Returns | Description |
|---|---|---|
| subscribe(handler) | () => void | Registers a listener. Returns an unsubscriber |
| unsubscribe(handler) | void | Removes a specific listener |
| emit(value) | void | Calls all registered listeners with the given value |
License
MIT — Nazario Mazzotti
