UI Patterns
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:

  1. Back button closes the dialog instead of navigating away from the page — the most common user complaint about SPAs.
  2. Refresh preserves the state — if the user reloads while the dialog is open, it re-opens.
  3. The URL is shareable — sending someone a link with ?dialog=true opens 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.

hooks/use-url-toggle.ts
"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 calls history.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.replaceState and popstate directly — 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 via URLSearchParams.
  • 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=true with an empty selection, the hook silently replaces the URL. On forward- button, it calls history.back() to bounce off.
  • SSR-safe. getParam returns null when window is not defined, so the initial state falls back to initialValue during server render. The URL is only read inside useState's lazy initialiser, which runs on the client during hydration.
  • Drop-in useState replacement. The return type is [boolean, (v: boolean) => void] — same shape as useState. Swapping useState(false)useUrlToggle("dialog", false) is the only change; the dialog component doesn't need to know how state is stored.
  • Multiple dialogs. Use different name values: useUrlToggle("edit", false) and useUrlToggle("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.

On this page