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.
Live preview
PropertyForm.svelte
Leave: the dialog steps in only while the field holds an unsaved edit. An alert stands in for the navigation.<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
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.