Unsaved Guard
Unsaved-changes guard: a root provider plus a useUnsavedGuard hook. Warns before reload, tab close and in-app link navigation, and returns a guard(proceed) to wrap programmatic navigation in a confirm dialog.
Example
Installation
npx shadcn@latest add https://hirael.com/r/unsaved-guard.jsonAPI
<UnsavedGuardProvider />
| Prop | Type | Default |
|---|---|---|
children* | React.ReactNode | — |
beforeUnloadWarn on reload / tab close via the browser's native prompt. Default true. | boolean | true |
onProceedCalled with the destination URL when the user confirms leaving via an in-app link. Use it to navigate through your router (e.g. `router.push`); defaults to a full navigation. | ((href: string) => void) | — |
defaultOptionsDefault leave-dialog copy, overridden per `useUnsavedGuard` call. | Omit<UnsavedGuardOptions, "when"> | — |
Component source
"use client";
import * as React from "react";
import {
ConfirmProvider,
useConfirm,
} from "@/registry/hirael/components/confirm";
export type UnsavedGuardOptions = {
/** Whether there are unsaved changes to guard. */
when: boolean;
/** Leave-dialog heading. */
title?: React.ReactNode;
/** Leave-dialog supporting line. */
description?: React.ReactNode;
/** Leave (proceed) button label. */
confirmText?: React.ReactNode;
/** Stay (cancel) button label. */
cancelText?: React.ReactNode;
};
/**
* Runs `proceed` once it is safe to navigate. With no unsaved changes it runs
* immediately; otherwise it asks for confirmation first. Resolves `true` when
* the navigation went ahead, `false` when it was cancelled.
*/
export type GuardNavigation = (
proceed?: () => void | Promise<void>,
) => Promise<boolean>;
type UnsavedGuardContextValue = {
register: (id: string, options: UnsavedGuardOptions) => void;
unregister: (id: string) => void;
confirmLeave: (options?: Partial<UnsavedGuardOptions>) => Promise<boolean>;
};
const UnsavedGuardContext =
React.createContext<UnsavedGuardContextValue | null>(null);
/**
* Registers an unsaved-changes guard. While `when` is true, leaving the page
* (reload, tab close, in-app link) is intercepted with a confirm dialog.
* Returns a `guard(proceed)` to wrap programmatic navigation. Must be used
* under an `<UnsavedGuardProvider>`.
*/
function useUnsavedGuard(options: UnsavedGuardOptions): GuardNavigation {
const ctx = React.useContext(UnsavedGuardContext);
if (!ctx) {
throw new Error(
"useUnsavedGuard must be used inside <UnsavedGuardProvider>",
);
}
const id = React.useId();
const { when, title, description, confirmText, cancelText } = options;
const latestRef = React.useRef(options);
React.useEffect(() => {
latestRef.current = { when, title, description, confirmText, cancelText };
});
React.useEffect(() => {
ctx.register(id, { when, title, description, confirmText, cancelText });
return () => ctx.unregister(id);
}, [ctx, id, when, title, description, confirmText, cancelText]);
return React.useCallback<GuardNavigation>(
(proceed) => {
const current = latestRef.current;
if (!current.when) {
return Promise.resolve(proceed?.()).then(() => true);
}
return ctx.confirmLeave(current).then((ok) => {
if (!ok) return false;
return Promise.resolve(proceed?.()).then(() => true);
});
},
[ctx],
);
}
export type UnsavedGuardProviderProps = {
children: React.ReactNode;
/** Warn on reload / tab close via the browser's native prompt. Default true. */
beforeUnload?: boolean;
/**
* Called with the destination URL when the user confirms leaving via an
* in-app link. Use it to navigate through your router (e.g. `router.push`);
* defaults to a full navigation.
*/
onProceed?: (href: string) => void;
/** Default leave-dialog copy, overridden per `useUnsavedGuard` call. */
defaultOptions?: Omit<UnsavedGuardOptions, "when">;
};
function UnsavedGuardProvider({
children,
beforeUnload = true,
onProceed,
defaultOptions,
}: UnsavedGuardProviderProps) {
return (
<ConfirmProvider>
<UnsavedGuardInner
beforeUnload={beforeUnload}
onProceed={onProceed}
defaultOptions={defaultOptions}
>
{children}
</UnsavedGuardInner>
</ConfirmProvider>
);
}
function UnsavedGuardInner({
children,
beforeUnload,
onProceed,
defaultOptions,
}: Required<Pick<UnsavedGuardProviderProps, "beforeUnload">> &
Pick<
UnsavedGuardProviderProps,
"onProceed" | "defaultOptions" | "children"
>) {
const confirm = useConfirm();
const guardsRef = React.useRef(new Map<string, UnsavedGuardOptions>());
const blockedRef = React.useRef(false);
const activeRef = React.useRef<UnsavedGuardOptions | null>(null);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const {
title: defTitle,
description: defDescription,
confirmText: defConfirmText,
cancelText: defCancelText,
} = defaultOptions ?? {};
const recompute = React.useCallback(() => {
let active: UnsavedGuardOptions | null = null;
for (const value of guardsRef.current.values()) {
if (value.when) {
active = value;
break;
}
}
blockedRef.current = active !== null;
activeRef.current = active;
}, []);
const register = React.useCallback(
(id: string, options: UnsavedGuardOptions) => {
guardsRef.current.set(id, options);
recompute();
},
[recompute],
);
const unregister = React.useCallback(
(id: string) => {
guardsRef.current.delete(id);
recompute();
},
[recompute],
);
const confirmLeave = React.useCallback(
(options?: Partial<UnsavedGuardOptions>) =>
confirm({
tone: "destructive",
title: options?.title ?? defTitle ?? "Discard unsaved changes?",
description:
options?.description ??
defDescription ??
"You have unsaved changes that will be lost if you leave.",
confirmText: options?.confirmText ?? defConfirmText ?? "Leave",
cancelText: options?.cancelText ?? defCancelText ?? "Stay",
}),
[confirm, defTitle, defDescription, defConfirmText, defCancelText],
);
React.useEffect(() => {
if (!beforeUnload) return;
const handler = (event: BeforeUnloadEvent) => {
if (!blockedRef.current) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [beforeUnload]);
React.useEffect(() => {
const root = wrapperRef.current;
if (!root) return;
const onClick = (event: MouseEvent) => {
if (!blockedRef.current || event.defaultPrevented) return;
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
const anchor =
event.target instanceof Element ? event.target.closest("a") : null;
if (
!anchor ||
anchor.target === "_blank" ||
anchor.hasAttribute("download")
) {
return;
}
const href = anchor.getAttribute("href");
if (!href || href.startsWith("#")) return;
let url: URL;
try {
url = new URL(anchor.href, window.location.href);
} catch {
return;
}
if (url.origin !== window.location.origin) return;
if (url.href === window.location.href) return;
event.preventDefault();
event.stopPropagation();
void confirmLeave(activeRef.current ?? undefined).then((ok) => {
if (!ok) return;
if (onProceed) {
onProceed(url.href);
} else {
window.location.assign(url.href);
}
});
};
root.addEventListener("click", onClick, true);
return () => root.removeEventListener("click", onClick, true);
}, [confirmLeave, onProceed]);
const value = React.useMemo<UnsavedGuardContextValue>(
() => ({ register, unregister, confirmLeave }),
[register, unregister, confirmLeave],
);
return (
<UnsavedGuardContext.Provider value={value}>
<div ref={wrapperRef} style={{ display: "contents" }}>
{children}
</div>
</UnsavedGuardContext.Provider>
);
}
export { UnsavedGuardProvider, useUnsavedGuard };
Dependencies
shadcn registry
confirm