Zero dependencies · TypeScript · ~1.0kB gzip

Know when your form
is dirty

Snapshot form state, check isDirty, list changedFields, and guard beforeunload — in 3 lines. No form library required.

✦ Try the live demo
Framework agnostic DOM & controlled forms beforeunload guard SSR safe ~1.0kB gzip
Why form-dirty

Everything you need, nothing you don't

🎯

isDirty in one read

Single boolean property. Always up to date. No manual comparisons, no deep-equal headaches.

📋

changedFields

Array of { name, original, current } for every field that differs. Show users exactly what changed.

🛡️

beforeunload guard

One option: beforeUnload: true. Prevents accidental navigation when the form is dirty. That's it.

🔄

snapshot() after save

Re-baseline current state with one call. After your API save succeeds, the form is "clean" again.

🚫

Zero dependencies

No React, no Lodash, no form library. Pure TypeScript class you drop into any project.

📦

DOM + controlled

Works with native HTML forms and controlled state (React, Vue, Svelte). Same API, same result.

Interactive Demo

Try it right here

Edit the form — watch dirty state update live

This form is wired up with form-dirty. Change any field to see it in action.

Form Status
Clean
Changed Fields
No changes yet
Get Started

Install in 30 seconds

npm install form-dirty
yarn add form-dirty
pnpm add form-dirty
<script type="module">
  import FormDirty from 'https://esm.sh/form-dirty';
</script>
import FormDirty from 'form-dirty';

const fd = new FormDirty({
  form: '#my-form',
  beforeUnload: true,
});

// Check anytime
console.log(fd.isDirty);         // true / false
console.log(fd.changedFields);  // [{ name, original, current }]
import FormDirty from 'form-dirty';

const fd = new FormDirty({
  fields: { name: '', email: '', bio: '' },
  beforeUnload: true,
  onDirtyChange: (dirty) => setBanner(dirty),
});

// Whenever your state changes
fd.update({ name: 'Ada', email: '', bio: '' });
console.log(fd.isDirty); // true
import { useEffect, useRef } from 'react';
import FormDirty from 'form-dirty';

function useFormDirty(fields) {
  const ref = useRef(null);

  useEffect(() => {
    ref.current = new FormDirty({
      fields, beforeUnload: true,
    });
    return () => ref.current?.destroy();
  }, []);

  useEffect(() => { ref.current?.update(fields); }, [fields]);

  return {
    get isDirty() { return ref.current?.isDirty ?? false; },
    get changedFields() { return ref.current?.changedFields ?? []; },
    snapshot: () => ref.current?.snapshot(),
  };
}
const fd = new FormDirty({
  form: '#settings-form',
  beforeUnload: true,
});

async function handleSave() {
  await fetch('/api/settings', {
    method: 'POST',
    body: getFormData(),
  });

  fd.snapshot(); // current state = new baseline
  console.log(fd.isDirty); // false
}
API Reference

Constructor options

Option Type Default Description
form HTMLFormElement | string DOM form element or CSS selector. Enables DOM mode.
fields Record<string, unknown> Initial field values. Enables controlled mode.
beforeUnload boolean false Auto-attach beforeunload guard when form is dirty.
onDirtyChange (dirty: boolean) => void Callback fired when dirty state transitions.

Properties & methods

Member Type Description
.isDirty boolean Whether any field differs from baseline.
.changedFields ChangedField[] Array of { name, original, current } for changed fields.
.snapshot() void Capture current state as the new clean baseline.
.update(fields) void Push new field values (controlled mode).
.guard(enable?) void Toggle the beforeunload guard on or off.
.destroy() void Remove all listeners. Call on unmount / cleanup.
bash
~/everything-frontend/form-dirty
$ cd ..