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
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.