Skip to main content
Urbicon UI
Back to Recipes

Unsaved Changes Guard

An edit form that intercepts navigation while it holds unsaved changes: a beforeNavigate use hook cancels the route change and asks through a three-exit ConfirmDialog, while closing the tab gets the native browser prompt.

Built with ConfirmDialog Input Button

Live preview

PropertyForm.svelte

Edit the name, then Leave: the dialog steps in only while the field holds an unsaved edit. An alert stands in for the navigation.
No changes
<script lang="ts">
  import { goto } from '$app/navigation';
  import { Button, ConfirmDialog, Input } from '@urbicon-ui/blocks';
  import { useUnsavedGuard } from './use-unsaved-guard.svelte';

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

  let showConfirm = $state(false);
  let resolveLeave: ((proceed: boolean) => void) | null = null;

  // Stand-in for your save call: the draft becomes the new baseline.
  function save() {
    originalName = name;
  }

  function reset() {
    name = originalName;
  }

  // The guard calls this instead of navigating. The dialog's three exits
  // resolve the promise: Cancel with false, the other two with true after
  // clearing the dirty state.
  function askUser(): Promise<boolean> {
    showConfirm = true;
    return new Promise((resolve) => (resolveLeave = resolve));
  }

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

  // The page's own way out. Any navigation trips the guard the same way: a
  // sidebar link, the back button, this goto. (The demo mocks this with an
  // alert; a docs page cannot leave itself.)
  function leave() {
    goto('/properties');
  }

  function saveAndLeave() {
    save();
    resolveLeave?.(true);
    resolveLeave = null;
  }

  function discardAndLeave() {
    reset();
    showConfirm = false;
    resolveLeave?.(true);
    resolveLeave = null;
  }

  function cancelLeave() {
    resolveLeave?.(false);
    resolveLeave = null;
  }
</script>

<!-- Lay it out in your page's own column; the cap keeps the field readable. -->
<div class="w-full max-w-md space-y-6">
  <Input
    label="Property name"
    bind:value={name}
    helper={dirty ? 'Unsaved changes' : 'No changes'}
    intent={dirty ? 'warning' : 'default'}
  />

  <div class="flex flex-wrap items-center gap-3">
    <Button intent="primary" onclick={leave}>Leave</Button>
    <Button intent="neutral" variant="outlined" onclick={save}>Save</Button>
    <Button intent="neutral" variant="ghost" onclick={reset}>Reset</Button>
  </div>
</div>

<ConfirmDialog
  bind:open={showConfirm}
  title="Unsaved changes"
  description="You have changes that haven't been saved yet. Save and continue, or cancel?"
  intent="warning"
  confirmLabel="Save and leave"
  cancelLabel="Cancel"
  onConfirm={saveAndLeave}
  onCancel={cancelLeave}
>
  <!-- The third exit. ConfirmDialog ships two buttons; extra actions render
       as children. -->
  <button
    type="button"
    class="text-danger hover:text-danger-emphasis text-sm underline-offset-2 hover:underline"
    onclick={discardAndLeave}
  >
    Discard changes and leave anyway
  </button>
</ConfirmDialog>

use-unsaved-guard.svelte.ts

The other file: PropertyForm.svelte imports it as a sibling — move it to $lib once more forms need it.
import { beforeNavigate, goto } from '$app/navigation';

export interface UnsavedGuardOptions {
  /** Reactive read of the dirty state, e.g. () => dirty. */
  isDirty: () => boolean;
  /**
   * Opens your dialog and resolves true to proceed. Clear the dirty state
   * before resolving true, or the retried navigation lands back here.
   */
  confirm: () => Promise<boolean>;
}

/**
 * Call once during component init. While isDirty() returns true, every
 * navigation waits for confirm(); closing or reloading the tab gets the
 * browser's own prompt.
 */
export function useUnsavedGuard(opts: UnsavedGuardOptions): void {
  beforeNavigate(async (nav) => {
    if (!opts.isDirty()) return;

    // cancel() must run before the first await: SvelteKit does not wait for
    // this callback. It stops an in-app navigation outright; on a 'leave'
    // navigation (tab close, reload) it arms the browser's generic prompt
    // instead — the only UI allowed at that point — so bail before the
    // dialog can open underneath it.
    nav.cancel();
    if (nav.type === 'leave') return;

    const proceed = await opts.confirm();
    if (!proceed || !nav.to) return;

    // The dialog cleared the dirty state before resolving true, so this
    // second attempt passes the guard. goto() only handles routes the
    // client-side router owns; an external target unloads the document.
    if (nav.willUnload) window.location.href = nav.to.url.href;
    else goto(nav.to.url);
  });
}

Three decisions

A recipe, not a component

The guard is app state end to end: what counts as dirty, what saving means, where discard resets to, which route the retry goes to. A library <UnsavedChangesGuard> would wrap five lines of setup in coupling to all four, so the pattern ships as this page plus the hook. You may not need either: a schema that tolerates saving on every change (settings, notes) can auto-save, and the question disappears. The guard is for forms where saving is an explicit step.

Three exits, not two

A two-button dialog forces a bad choice: commit whatever is in the form, or stay on the page. The third path, discarding, is the one people reach for when the edit was exploratory, so it rides in as ConfirmDialog children. It renders as a quiet danger-coloured link rather than a third button on purpose: it throws work away, and the footer pair should stay the obvious pick.

The browser owns the leaving prompt

Browsers stopped rendering custom text on tab close years ago: on a 'leave' navigation the most an app gets is nav.cancel(), which arms the generic native confirmation. The three-exit dialog is therefore only possible for navigations the router owns, and the hook bails on 'leave' so the dialog cannot open underneath the native prompt. It is also why the demo mocks navigation with an alert: a docs page cannot leave itself to show the real interception.