Skip to content

useFormDirty ​

Tracks unsaved changes and stops the reader losing them — through the app's own dialog on a route change, and the browser's on a tab close.

Import ​

ts
import { useFormDirty } from '@core/schema/useFormDirty'

Signature ​

ts
function useFormDirty<T>(
  initial: T,
  current: Ref<T>,
  options?: UseFormDirtyOptions,
): UseFormDirtyReturn

interface UseFormDirtyOptions {
  confirmMessage?: string
  confirmDescription?: string
}

Parameters ​

ParameterTypeDefaultDescription
initialTrequiredThe baseline. Snapshotted once, at the call.
currentRef<T>requiredThe live values.
options.confirmMessagestring'common.unsavedChangesLeave'The dialog's title. A translation key or a literal.
options.confirmDescriptionstring'common.unsavedChangesLost'The line under it.

Returns ​

MemberTypeDescription
isDirtyReadonly<Ref<boolean>>Whether current differs from the baseline, by deep comparison.
markPristine() => voidRe-snapshots from current. Call it after a successful save.

Example ​

vue
<script setup lang="ts">
import { useFormDirty } from '@core/schema/useFormDirty'
import { ref } from 'vue'

interface Profile {
  name: string
  title: string
}

const initial: Profile = { name: 'Amina Haddad', title: 'Operations lead' }
const form = ref<Profile>({ ...initial })

const { isDirty, markPristine } = useFormDirty(initial, form)

async function save() {
  await fetch('/api/profile', { method: 'PATCH', body: JSON.stringify(form.value) })
  markPristine()
}
</script>

<template>
  <form class="space-y-3" @submit.prevent="save()">
    <input v-model="form.name" type="text">
    <input v-model="form.title" type="text">
    <button type="submit" :disabled="!isDirty">
      {{ isDirty ? 'Save changes' : 'Saved' }}
    </button>
  </form>
</template>

Notes ​

  • The route guard is async, and that is the whole point. vue-router awaits a promise returned from a leave hook, so the question can be asked through the app's own confirm dialog instead of the browser's gray box. It uses the default variant, because the title is already a question.
  • The beforeunload dialog cannot be styled. Closing a tab happens outside the app's lifetime, so the browser draws its own and ignores your text. A non-empty returnValue is still what triggers it.
  • The baseline is a structural clone. Without it, mutating the object you passed as initial would shift the baseline and isDirty would read false no matter how much was edited.
  • The snapshot is taken once. After a save, either remount the form or call markPristine(), or the guard keeps firing.
  • Comparison is deep, so key order and nesting do not produce false positives.