forms-delta
v0.1.1
Published
Get a precise diff of what changed between a form's initial and current state. Zero dependencies. ~1.5kb gzipped.
Maintainers
Readme
forms-delta
Know exactly what changed. Every time.
Zero dependencies · ~1.5kb gzipped · TypeScript-first · Framework agnostic
What is forms-delta?
forms-delta is a tiny utility that compares a form's original saved state against its current live state and tells you precisely what changed — which fields, what the old values were, and what the new values are.
You have saved data. The user edits it. You need to know what changed. That's it.
import { delta } from 'forms-delta';
const result = delta(
{ name: 'Usama', role: 'admin', city: 'Lahore' }, // saved
{ name: 'Usama', role: 'editor', city: 'Karachi' } // current
);
result.changed; // ['role', 'city']
result.unchanged; // ['name']
result.isDirty; // true
result.diff.role; // { from: 'admin', to: 'editor' }
result.patch; // { role: 'editor', city: 'Karachi' }Why does this exist?
Every application that has edit forms faces the same problem: knowing what the user actually changed.
Without a solution, developers either send the entire object to the API on every save — wasteful, risky, and destructive to concurrent edits — or they write a custom diffing function by hand on every project, every time, with no consistency.
forms-delta solves this once, correctly, in 1.5kb.
Common problems it eliminates:
- Save button that stays enabled even when nothing changed
- PUT requests that overwrite all fields instead of PATCHing only what changed
- Manually written diffing logic duplicated across every edit form in the codebase
- No audit trail because nobody tracked what actually changed
- Users losing work with no "unsaved changes" warning
Install
npm install forms-deltayarn add forms-deltapnpm add forms-deltaUse cases
Smart PATCH requests
Send only the fields that changed to your API — not the entire object. Safer, faster, and plays nicely with concurrent users editing the same record.
import { getValues } from 'forms-delta';
const patch = getValues(savedUser, formState);
// { role: 'editor' } — only what changed
await api.patch(`/users/${id}`, patch);Enable / disable Save button
isDirty returns false until something actually changes. No more Save buttons that do nothing, or that users click repeatedly out of uncertainty.
import { isDirty } from 'forms-delta';
const dirty = isDirty(savedUser, formState);
<button disabled={!dirty}>Save changes</button>Audit logs
Record exactly who changed what, from which value, to which value. A legal and compliance requirement in finance, healthcare, and enterprise SaaS — generated automatically.
import { toChangeLog } from 'forms-delta';
const log = toChangeLog(savedUser, formState, {
role: 'User Role',
email: 'Email Address',
});
// "User Role: 'admin' → 'editor'"
// "Email Address: '[email protected]' → '[email protected]'""You have unsaved changes" warning
Power the browser unload prompt that saves users from accidentally losing work.
window.addEventListener('beforeunload', (e) => {
if (isDirty(savedUser, formState)) e.preventDefault();
});Confirmation dialogs before submit
Show the user exactly what they are about to change before they confirm.
const changes = toChangeLog(savedUser, formState);
// Render: "You are about to change 2 fields: Role and City. Confirm?"Trigger side effects on specific fields
Run logic only when a specific sensitive field is modified — not on every keystroke.
import { hasFieldChanged } from 'forms-delta';
if (hasFieldChanged('email', savedUser, formState)) {
await sendVerificationEmail(formState.email);
}Undo / redo in forms
Full undo/redo history that only records a new snapshot when something actually changed — not on every render.
import { FormHistory } from 'forms-delta';
const history = new FormHistory(initialState);
history.push(currentState); // only saves if something changed
history.undo(); // revert one step
history.redo(); // reapply one step
history.canUndo; // boolean — drive your UI
history.canRedo; // booleanAPI reference
delta(initial, current, options?)
The core function. Compares two plain objects and returns a full structured diff.
import { delta } from 'forms-delta';
const result = delta(initial, current, options);Parameters
| Param | Type | Description |
|-----------|----------------|----------------------------------|
| initial | T | The original / saved form values |
| current | T | The current live form state |
| options | DeltaOptions | Optional configuration |
Returns DeltaResult<T>
| Field | Type | Description |
|-------------|------------------|--------------------------------------------------|
| changed | (keyof T)[] | Names of fields that changed |
| unchanged | (keyof T)[] | Names of fields that did not change |
| diff | Partial<...> | Per-field { from, to } objects for each change |
| isDirty | boolean | true if any field changed |
| patch | Partial<T> | New values of changed fields — ready for PATCH |
isDirty(initial, current, options?)
Returns true if any field changed. Use to enable or disable a Save button.
import { isDirty } from 'forms-delta';
isDirty(savedUser, formState); // true | falsegetChanged(initial, current, options?)
Returns an array of the field names that changed.
import { getChanged } from 'forms-delta';
getChanged({ a: 1, b: 2 }, { a: 99, b: 2 }); // ['a']getValues(initial, current, options?)
Returns only the new values of fields that changed. The ideal PATCH body.
import { getValues } from 'forms-delta';
const patch = getValues(savedUser, formState);
await api.patch(`/users/${id}`, patch);getOriginals(initial, current, options?)
Returns the old values of fields that changed. Useful for "was / now" audit displays.
import { getOriginals } from 'forms-delta';
getOriginals(savedUser, formState);
// { role: 'admin' } ← the value before the user changed itcountChanged(initial, current, options?)
Returns the number of fields that changed.
import { countChanged } from 'forms-delta';
const count = countChanged(initial, current);
// Use in UI: `Save (${count} changes)`hasFieldChanged(field, initial, current, options?)
Check whether one specific named field has changed.
import { hasFieldChanged } from 'forms-delta';
if (hasFieldChanged('email', savedUser, formState)) {
await sendVerificationEmail();
}toChangeLog(initial, current, labels?, options?)
Converts a diff into an array of human-readable change log entries. Each entry includes the field name, display label, old value, new value, and a formatted summary string.
import { toChangeLog } from 'forms-delta';
const log = toChangeLog(
{ role: 'admin', city: 'Lahore' },
{ role: 'editor', city: 'Karachi' },
{ role: 'User Role', city: 'City' } // optional display labels
);
// [
// { field: 'role', label: 'User Role', from: 'admin', to: 'editor',
// summary: 'User Role: "admin" → "editor"' },
// { field: 'city', label: 'City', from: 'Lahore', to: 'Karachi',
// summary: 'City: "Lahore" → "Karachi"' }
// ]patch(target, diffResult)
Apply a delta result to a different object. Returns a new object — does not mutate the target. Useful for syncing a changed record to another part of your app.
import { delta, patch } from 'forms-delta';
const result = delta(initial, current);
const updated = patch(someOtherRecord, result);FormHistory
A complete undo/redo history stack built on top of delta. Only records a new snapshot when something actually changed — duplicate states are ignored automatically.
import { FormHistory } from 'forms-delta';
const history = new FormHistory(initialFormState);
// Call on every form change
history.push(currentFormState);
// Undo / redo
const prev = history.undo(); if (prev) setForm(prev);
const next = history.redo(); if (next) setForm(next);
// State
history.canUndo; // boolean
history.canRedo; // boolean
history.length; // total number of history entries
history.position; // current index in the stack
history.current; // state at current position
// Control
history.reset(); // jump back to the very first state
history.clear(); // discard all history, keep only current state
history.inspect(); // { position, total, canUndo, canRedo }Options
Every function accepts an optional DeltaOptions object as its last argument.
| Option | Type | Default | Description |
|----------------|-----------------------------------|---------|------------------------------------------------|
| deepArrays | boolean | true | Compare arrays by value, not reference |
| nullishEqual | boolean | false | Treat null and undefined as equal |
| ignore | string[] | [] | Skip these fields entirely |
| pick | string[] | — | Only compare these specific fields |
| comparators | Record<string, (a, b) => boolean> | {} | Custom equality function per field |
Examples
// Ignore timestamp fields that always differ
delta(initial, current, { ignore: ['updatedAt', 'createdAt'] });
// Only check whether the email field changed
delta(initial, current, { pick: ['email'] });
// Case-insensitive name comparison
delta(initial, current, {
comparators: {
name: (a, b) => String(a).toLowerCase() === String(b).toLowerCase(),
},
});
// Treat numeric scores within 0.5 as equal
delta(initial, current, {
comparators: {
score: (a, b) => Math.abs(Number(a) - Number(b)) < 0.5,
},
});Framework examples
React / Next.js
import { useState } from 'react';
import { isDirty, getValues, countChanged } from 'forms-delta';
function EditUserForm({ savedUser, onSave }) {
const [form, setForm] = useState(savedUser);
const dirty = isDirty(savedUser, form);
const count = countChanged(savedUser, form);
const handleSave = async () => {
const changes = getValues(savedUser, form); // Only changed fields
await onSave(changes);
};
return (
<>
<input
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/>
<button disabled={!dirty} onClick={handleSave}>
{dirty ? `Save (${count} changes)` : 'No changes'}
</button>
</>
);
}Vue 3
import { computed, ref } from 'vue';
import { delta } from 'forms-delta';
const saved = { name: 'Usama', role: 'admin' };
const form = ref({ ...saved });
const result = computed(() => delta(saved, form.value));
const isDirty = computed(() => result.value.isDirty);
const patch = computed(() => result.value.patch);Angular
import { delta, getValues } from 'forms-delta';
export class EditComponent implements OnInit {
savedUser = { name: '', email: '' };
ngOnInit() {
this.form.valueChanges.subscribe(current => {
this.isDirty = delta(this.savedUser, current).isDirty;
});
}
save() {
const changes = getValues(this.savedUser, this.form.value);
this.api.patch(changes).subscribe();
}
}Node.js / Express
import { delta, toChangeLog } from 'forms-delta';
app.patch('/users/:id', async (req, res) => {
const stored = await db.users.findById(req.params.id);
const { isDirty, patch, changed, diff } = delta(stored, req.body, {
ignore: ['updatedAt', 'id'],
});
if (!isDirty) return res.json({ message: 'No changes detected.' });
await db.users.update(req.params.id, patch);
await auditLog.create({
targetId: req.params.id,
changedFields: changed,
diff,
summary: toChangeLog(stored, req.body).map(e => e.summary),
timestamp: new Date(),
});
res.json({ updated: changed });
});TypeScript
Full generic type inference — T is inferred automatically from your objects. You never need to specify it unless you want to.
interface UserForm {
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
}
const result = delta<UserForm>(savedUser, formState);
result.changed; // ('name' | 'email' | 'role')[]
result.diff.role; // FieldDiff<'admin' | 'editor' | 'viewer'> | undefined
result.diff.role?.from // 'admin' | 'editor' | 'viewer'
result.patch; // Partial<UserForm>What gets compared
| Type | Behaviour |
|-------------------|-------------------------------------------------------|
| string | Strict equality |
| number | Strict equality |
| boolean | Strict equality |
| null | Strict equality (or equal to undefined with option) |
| undefined | Strict equality (or equal to null with option) |
| Date | Compared by .getTime() — not by reference |
| Array | Deep value comparison by default |
| Nested object | Deep recursive equality |
| Added fields | Detected as changed |
| Removed fields | Detected as changed |
Contributing
Contributions, bug reports, and feature requests are welcome. Please open an issue before submitting a pull request for significant changes.
git clone https://github.com/devmubs/forms-delta
cd forms-delta
npm install
npm run test:watchLicense
MIT © Usama Saleem
