Change tracking
Native dirty tells you a field was interacted with. changed tells you its value differs from a baseline, which is what you want for diffs, unsaved-changes guards, and server reconciliation.
@mmstack/formsnpm
#Tracking a form
Add trackChanges(model) to a form. It adopts the model's initial value as the baseline, captured on the first effect flush after construction, so any edits made before then fold into the baseline and changed is meaningful with no extra wiring. Read it per field with injectChanged.
import { form } from '@angular/forms/signals';
import { trackChanges, injectChanged } from '@mmstack/forms';
readonly model = signal<User>(emptyUser());
readonly f = form(this.model, trackChanges(this.model)); // baseline = initial value
// in any control:
readonly changed = injectChanged(); // Signal<boolean> Leaves compare against their own baseline and containers aggregate, with an Object.is short-circuit so a change only walks its own branch of the tree.
#Async initial data
When the initial data arrives asynchronously, pass { manualCommit: true }. That skips the automatic baseline and every field reads as changed until you call commitChanges once the data lands. Call commitChanges(f) at any time to re-baseline to the current values, for example in the success path of a save.
import { commitChanges, trackChanges } from '@mmstack/forms';
readonly f = form(this.model, trackChanges(this.model, { manualCommit: true }));
// once the async data has loaded into the model:
commitChanges(this.f); // establish the baseline now#Custom equality
Equality is Object.is at leaves by default. Override it per path with changedEqual, or replace the comparison entirely with changedWith.
import { changedEqual, changedWith, trackChanges } from '@mmstack/forms';
form(model, (p) => {
changedEqual(p.profile.avatar, (a, b) => normalize(a) === normalize(b));
changedWith(p.tags, (initial, current) => current.length !== initial.length);
trackChanges(model)(p);
}); Arrays diff their items by value, index by index, so a reorder of identity-tracked items still registers. One consequence: an override placed on an item path changes that item's own changed signal, not the array's. To change how the container itself diffs, put the override on the array path.
#Reading the diff and resetting
changedValues(f) returns a deep-partial of just the changed fields, ready to send to a server, or undefined when nothing differs. That read is an untracked snapshot for submit time. When you want the same information live, for a badge or a preview, changedCount(f) is a signal of how many units differ, and changedPaths(f) is a signal of the dot-joined paths that make up the diff, one per extraction unit. All three follow the same granularity: objects narrow per property, while arrays and leaves come through whole.
resetChanged reverts to the baseline, and resetInitial sets a new value and baseline together.
import { changedValues, changedCount, changedPaths } from '@mmstack/forms';
const patch = changedValues(this.f); // DeepPartial<User> | undefined, untracked snapshot
const count = changedCount(this.f); // Signal<number>
const paths = changedPaths(this.f); // Signal<readonly string[]>, e.g. ['name', 'address.city'] Reach for changedCount when you need a plain "3 unsaved changes" count. It reads the same tracking machinery as injectChanged(), so a guard that asks "does this form have unsaved edits" can gate on changedCount(f)() > 0 without touching any internal metadata key.
#Server reconciliation
When a save returns the stored entity, or another client's edit arrives, reconcile(f, incoming) merges that data back into the form without clobbering edits still in flight. Unchanged fields adopt the incoming value, changed fields keep their edit, and every field's baseline becomes the incoming value, so a kept edit reads as changed against the new server state.
The default merge walks objects per property and treats arrays and leaves as whole units. Override a single path with reconcileWith when a field needs its own merge, for example a smart array merge or a leaf that should always defer to the server. Your function receives the current and incoming values plus whether that field changed, and returns the value to keep.
import { reconcile, reconcileWith, trackChanges } from '@mmstack/forms';
const f = form(model, (p) => {
// a leaf that should always take the server value, even if edited locally:
reconcileWith(p.updatedAt, ({ incoming }) => incoming);
trackChanges(model)(p);
});
// on a save response or a pushed update:
reconcile(f, serverUser); // keeps in-flight edits, adopts the rest Both change tracking and reconciliation are also available as composition fragments (changeTracking, reconciliation) when you build field types.
#Submitting the diff
submitChanges(f, target) is the whole save recipe as one call. It runs Angular's submit() first, so validators run, fields touch, and an invalid form blocks with no request. Then it sends the diff through the target and re-baselines what was sent on success, leaving dirty state alone on failure so a retry keeps the edits.
The target is any object with a mutateAsync method, typed as SubmitTarget. An @mmstack/resourcemutationResource fits that shape, and brings its own queue and supersede semantics along. By default the payload is the changed subset from changedValues, and a form with nothing changed resolves true without a request. The returned function is a () => Promise<boolean>: true when the form was valid and the save landed, false when validation blocked it or the error mapper attached field errors.
import { submitChanges, trackChanges } from '@mmstack/forms';
readonly f = form(this.model, trackChanges(this.model));
readonly updateUser = mutationResource(/* ... */); // any { mutateAsync }
// sends the diff, re-baselines what was sent, merges the server echo back in:
readonly save = submitChanges(this.f, this.updateUser, { onSuccess: 'reconcile' });
// template: <button (click)="save()" [disabled]="f().submitting()">Save</button>SubmitChangesOptions tunes the rest. Set payload: 'full' to always send the whole value instead of the diff. On success the submitted units re-baseline to the values that were actually sent, so an edit that landed mid-request stays dirty rather than being absorbed silently. That re-baseline is the 'commit' default. Choose onSuccess: 'reconcile' to additionally merge the mutation's result back into the form, offered only when the result is assignable to the form model. An errors mapper turns a failure into Angular's server-error channel instead of rethrowing, and ignoreValidators is forwarded to submit().