···11+import { useCallback, useRef, useState } from "react";
22+import { useSearchParams } from "next/navigation";
33+44+/**
55+ * Syncs React state to URL query parameters so filter selections persist
66+ * across page refreshes and are shareable via URL.
77+ *
88+ * Uses `window.history.replaceState` for synchronous URL updates (no
99+ * Next.js router transition overhead). Next.js 14+ automatically syncs
1010+ * its hooks (`useSearchParams`, etc.) when the history API is called.
1111+ *
1212+ * Each parameter is defined by a `QueryParam<T>` config that controls how
1313+ * a value of type `T` is serialized to/from one or more query string keys.
1414+ *
1515+ * @example
1616+ * // Simple string param (?metric=pageviews, default "visitors")
1717+ * let [metric, setMetric] = useQueryState<"visitors" | "pageviews">({
1818+ * toParams: (v) => ({ metric: v === "visitors" ? null : v }),
1919+ * fromParams: (get) => get("metric") === "pageviews" ? "pageviews" : "visitors",
2020+ * });
2121+ *
2222+ * // Optional value with multi-key serialization (?post=my-slug)
2323+ * let [postPath, setPostPath] = useQueryState<string | undefined>({
2424+ * fromParams: (get) => get("post") ?? undefined,
2525+ * toParams: (v) => ({ post: v ?? null }),
2626+ * });
2727+ */
2828+2929+/** Configuration for a single piece of query-synced state. */
3030+export type QueryParam<T> = {
3131+ /**
3232+ * Deserialize from query params → state value.
3333+ * Called once on mount to initialize from the URL.
3434+ * Should return the default value when no relevant params are present.
3535+ * @param get - reads a single query param by key (returns `string | null`)
3636+ */
3737+ fromParams: (get: (key: string) => string | null) => T;
3838+3939+ /**
4040+ * Serialize state value → query param updates.
4141+ * Return a record of key → string (to set) or key → null (to remove).
4242+ * Keys set to `null` are deleted from the URL, keeping it clean for defaults.
4343+ */
4444+ toParams: (value: T) => Record<string, string | null>;
4545+};
4646+4747+/**
4848+ * Like `useState`, but the value is initialized from URL query params on mount
4949+ * and the URL is updated synchronously via `history.replaceState` whenever
5050+ * the setter is called.
5151+ *
5252+ * Returns `[value, setValue]` — a drop-in replacement for `useState`.
5353+ */
5454+export function useQueryState<T>(
5555+ config: QueryParam<T>,
5656+): [T, (value: T) => void] {
5757+ let searchParams = useSearchParams();
5858+5959+ // Use a ref for toParams so the setter callback identity is stable
6060+ // even when config is an inline object literal.
6161+ let toParamsRef = useRef(config.toParams);
6262+ toParamsRef.current = config.toParams;
6363+6464+ let [value, _setValue] = useState<T>(() =>
6565+ config.fromParams((key) => searchParams.get(key)),
6666+ );
6767+6868+ let setValue = useCallback((next: T) => {
6969+ _setValue(next);
7070+ let updates = toParamsRef.current(next);
7171+ // Read directly from window.location so we always merge against
7272+ // the latest URL, even if multiple setters fire in the same frame.
7373+ let params = new URLSearchParams(window.location.search);
7474+ for (let [key, v] of Object.entries(updates)) {
7575+ if (v === null) {
7676+ params.delete(key);
7777+ } else {
7878+ params.set(key, v);
7979+ }
8080+ }
8181+ let qs = params.toString();
8282+ let url = qs
8383+ ? `${window.location.pathname}?${qs}`
8484+ : window.location.pathname;
8585+ window.history.replaceState(history.state, "", url);
8686+ }, []);
8787+8888+ return [value, setValue];
8989+}