npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

forms-delta

Know exactly what changed. Every time.

Zero dependencies · ~1.5kb gzipped · TypeScript-first · Framework agnostic

npm version downloads bundle size license TypeScript


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-delta
yarn add forms-delta
pnpm add forms-delta

Use 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;              // boolean

API 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 | false

getChanged(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 it

countChanged(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:watch

License

MIT © Usama Saleem