Hooks
URL Dialog
Persist dialog open/close state in the URL so the back button closes it and the link is shareable.
Examples
Dialog with back-button support
A modal whose open state lives in the URL as a query parameter
(?dialog=true). Three things happen that don't with a plain useState:
- Back button closes the dialog instead of navigating away from the page — the most common user complaint about SPAs.
- Refresh preserves the state — if the user reloads while the dialog is open, it re-opens.
- The URL is shareable — sending someone a link with
?dialog=trueopens the dialog on load.
Based on stereobooster.com/posts/react-hook-to-persist-state-of-a-dialog-in-url.
Open it, then press your browser's back button — it closes the dialog instead of leaving the page.
"use client";
import * as React from "react";
function getParam(name: string): string | null {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get(name);
}
function buildUrl(name: string, value: boolean, initialValue: boolean): string {
const params = new URLSearchParams(window.location.search);
if (value === initialValue) {
params.delete(name);
} else {
params.set(name, String(value));
}
const search = params.toString();
return `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`;
}
/**
* Like `useState<boolean>`, but mirrors the value in the URL as a query
* parameter. Creates at most one history entry so the back button closes
* the dialog in a single press.
*
* @param name — query-param key, e.g. `"dialog"`
* @param initialValue — default state when the param is absent (usually `false`)
* @param precondition — when `false`, prevents opening and cleans the URL.
* Useful when the dialog depends on a selection that might be empty.
*/
export function useUrlToggle(
name: string,
initialValue: boolean = false,
precondition?: boolean,
): [boolean, (value: boolean) => void] {
const preconditionRef = React.useRef(precondition);
preconditionRef.current = precondition;
// Read the URL once on mount to seed the initial state.
const [initialUrlValue] = React.useState(() => {
const param = getParam(name);
if (param === null) return initialValue;
return param === "true";
});
const [state, setState] = React.useState(
preconditionRef.current === false ? initialValue : initialUrlValue,
);
const currentRef = React.useRef(state);
const pushedRef = React.useRef(false);
// On initial load: if precondition blocks and URL has the flag, clean it up.
React.useEffect(() => {
if (preconditionRef.current === false && initialUrlValue !== initialValue) {
history.replaceState(
null,
"",
buildUrl(name, initialValue, initialValue),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Listen for back / forward navigation.
React.useEffect(() => {
const onPopState = () => {
const param = getParam(name);
const newValue = param === null ? initialValue : param === "true";
if (preconditionRef.current === false && newValue !== initialValue) {
history.back();
return;
}
currentRef.current = newValue;
setState(newValue);
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [name, initialValue]);
const toggle = React.useCallback(
(newValue: boolean) => {
if (preconditionRef.current === false && newValue !== initialValue)
return;
if (currentRef.current === newValue) return;
if (newValue === initialValue && pushedRef.current) {
// Going back to initial → reuse the history entry we pushed.
history.back();
} else {
pushedRef.current = true;
history.pushState(null, "", buildUrl(name, newValue, initialValue));
}
currentRef.current = newValue;
setState(newValue);
},
[name, initialValue],
);
return [state, toggle];
}Usage:
const [open, setOpen] = useUrlToggle("dialog", false);
<Dialog open={open} onOpenChange={setOpen}>
{/* ... */}
</Dialog>;Notes:
- One history entry, not two. Opening pushes a single entry
(
?dialog=true). Closing callshistory.back()instead of pushing again — so the user's history stays clean. Pressing back from the dialog returns to the exact page they were on, not to a second copy of the same page with the param stripped. - Router-agnostic. The hook uses
history.pushState/history.replaceStateandpopstatedirectly — no dependency on Next.js router, React Router, or any other framework. It works in any React SPA. Existing query params and the hash are preserved viaURLSearchParams. - Precondition gate. The optional third argument blocks the dialog
when
false— e.g.useUrlToggle("delete", false, selected.size > 0)prevents opening the delete-confirmation dialog when nothing is selected. On initial load, if someone navigates to?delete=truewith an empty selection, the hook silently replaces the URL. On forward- button, it callshistory.back()to bounce off. - SSR-safe.
getParamreturnsnullwhenwindowis not defined, so the initial state falls back toinitialValueduring server render. The URL is only read insideuseState's lazy initialiser, which runs on the client during hydration. - Drop-in
useStatereplacement. The return type is[boolean, (v: boolean) => void]— same shape asuseState. SwappinguseState(false)→useUrlToggle("dialog", false)is the only change; the dialog component doesn't need to know how state is stored. - Multiple dialogs. Use different
namevalues:useUrlToggle("edit", false)anduseUrlToggle("delete", false). Each gets its own query param and its own history entry. Opening both stacks two entries; back closes the most recent one. - For richer URL state (strings, objects, arrays, compression), see URL State.