Skip to main content
Urbicon UI
Back to Recipes

Unsaved Changes Guard

Guards against data loss when leaving a page with unsaved changes — combines ConfirmDialog (in-app confirm) and window.beforeunload (browser confirm). SvelteKit pattern with a beforeNavigate hook for in-app route changes.

Live Preview

No changes

Features

  • dirty flag as a $derived diff (name !== originalName) — no manual flag that can drift out of sync
  • beforeNavigate (SvelteKit) intercepts in-app route changes → ConfirmDialog
  • window.beforeunload for browser close, refresh, external links → native browser confirm
  • Cleanup-safe — the beforeunload listener is removed in onDestroy
  • Reusable as a use hook (no component mount required)

Code

lib/use-unsaved-guard.svelte.ts

import { onDestroy } from 'svelte';
import { beforeNavigate, type Navigation } from '$app/navigation';

export interface UnsavedGuardOptions {
  /** Reactive getter — true when unsaved changes exist. */
  isDirty: () => boolean;
  /** Called before navigating; should return true when it is OK to proceed. */
  confirm: () => Promise<boolean>;
}

/**
 * Mounts beforeNavigate (SvelteKit) + window.beforeunload (browser),
 * so the app protects unsaved changes before leaving.
 *
 * @example
 * ```ts
 * let dirty = $derived(name !== originalName);
 * useUnsavedGuard({ isDirty: () => dirty, confirm: askUser });
 * ```
 */
export function useUnsavedGuard(opts: UnsavedGuardOptions): void {
  // 1) SvelteKit-internal navigation
  beforeNavigate(async (nav: Navigation) => {
    if (!opts.isDirty()) return;
    if (nav.cancel === undefined) return; // SSR / non-cancellable
    nav.cancel();
    const proceed = await opts.confirm();
    if (proceed && nav.to) {
      // User confirmed — re-trigger navigation
      window.location.href = nav.to.url.href;
    }
  });

  // 2) Browser-level: close, refresh, external link
  function handleBeforeUnload(e: BeforeUnloadEvent) {
    if (!opts.isDirty()) return;
    e.preventDefault();
    // Modern browsers ignore the message — just need preventDefault + returnValue
    e.returnValue = '';
  }

  if (typeof window !== 'undefined') {
    window.addEventListener('beforeunload', handleBeforeUnload);
    onDestroy(() => window.removeEventListener('beforeunload', handleBeforeUnload));
  }
}

Usage in a form page

<script lang="ts">
  import { ConfirmDialog, Input, Button } from '@urbicon-ui/blocks';
  import { useUnsavedGuard } from '$lib/use-unsaved-guard.svelte';

  let originalName = $state('Sunset Heights');
  let name = $state(originalName);
  let dirty = $derived(name !== originalName);

  let dialogOpen = $state(false);
  let resolveDialog: ((proceed: boolean) => void) | null = null;

  function askUser(): Promise<boolean> {
    dialogOpen = true;
    return new Promise<boolean>((resolve) => {
      resolveDialog = resolve;
    });
  }

  function onConfirm() {
    // user wants to save then proceed → save first
    originalName = name;
    dialogOpen = false;
    resolveDialog?.(true);
    resolveDialog = null;
  }

  function onDiscard() {
    // user wants to discard → reset, then proceed
    name = originalName;
    dialogOpen = false;
    resolveDialog?.(true);
    resolveDialog = null;
  }

  function onCancel() {
    dialogOpen = false;
    resolveDialog?.(false);
    resolveDialog = null;
  }

  useUnsavedGuard({ isDirty: () => dirty, confirm: askUser });
</script>

<Input label="Property name" bind:value={name} />

<ConfirmDialog
  bind:open={dialogOpen}
  title="Unsaved changes"
  description="What do you want to do with the changes?"
  intent="warning"
  confirmLabel="Save and leave"
  cancelLabel="Cancel"
  {onConfirm}
  {onCancel}
>
  <button
    type="button"
    class="text-danger text-sm hover:underline"
    onclick={onDiscard}
  >
    Discard changes and leave anyway
  </button>
</ConfirmDialog>

Best Practices

Three actions instead of two

Save and leave, Discard, Cancel — the common mistake is leaving out "Discard". That forces the user to either save (even broken data) or not navigate at all. Three clear paths solve it.

dirty flag from a real diff

dirty should come from a comparison against the original state, not from a manual flag. Otherwise you risk false positives (the user types something and deletes it again → no diff, but the flag is still true). $derived(name !== originalName) is robust.

Don't overload browser beforeunload

Modern browsers no longer show app-specific text for beforeunload — just a generic "Do you really want to leave this page?". So beforeunload is only a fallback for browser close/refresh. The more important protection is the app's own ConfirmDialog on internal route changes.

Auto-save as an alternative

If the schema allows auto-save (settings, profile, notes), it spares you the whole guard. The guard is needed when saving is explicit (wizard with submit, form with validation). Ask yourself: would auto-save break the user's workflow? If not → no guard needed.

Why a recipe instead of a component?

The guard is app state, not a UI pattern: dirty tracking, save action, discard action, and the beforeNavigate integration are all app-specific. A library component <UnsavedChangesGuard> would only offer convenience for 5 lines of setup — at noticeably more coupling. Recipe + use hook is the clean separation.