a tool for shared writing and social publishing
1import { useCallback, useRef, useState } from "react";
2import { useSearchParams } from "next/navigation";
3
4/**
5 * Syncs React state to URL query parameters so filter selections persist
6 * across page refreshes and are shareable via URL.
7 *
8 * Uses `window.history.replaceState` for synchronous URL updates (no
9 * Next.js router transition overhead). Next.js 14+ automatically syncs
10 * its hooks (`useSearchParams`, etc.) when the history API is called.
11 *
12 * Each parameter is defined by a `QueryParam<T>` config that controls how
13 * a value of type `T` is serialized to/from one or more query string keys.
14 *
15 * @example
16 * // Simple string param (?metric=pageviews, default "visitors")
17 * let [metric, setMetric] = useQueryState<"visitors" | "pageviews">({
18 * toParams: (v) => ({ metric: v === "visitors" ? null : v }),
19 * fromParams: (get) => get("metric") === "pageviews" ? "pageviews" : "visitors",
20 * });
21 *
22 * // Optional value with multi-key serialization (?post=my-slug)
23 * let [postPath, setPostPath] = useQueryState<string | undefined>({
24 * fromParams: (get) => get("post") ?? undefined,
25 * toParams: (v) => ({ post: v ?? null }),
26 * });
27 */
28
29/** Configuration for a single piece of query-synced state. */
30export type QueryParam<T> = {
31 /**
32 * Deserialize from query params → state value.
33 * Called once on mount to initialize from the URL.
34 * Should return the default value when no relevant params are present.
35 * @param get - reads a single query param by key (returns `string | null`)
36 */
37 fromParams: (get: (key: string) => string | null) => T;
38
39 /**
40 * Serialize state value → query param updates.
41 * Return a record of key → string (to set) or key → null (to remove).
42 * Keys set to `null` are deleted from the URL, keeping it clean for defaults.
43 */
44 toParams: (value: T) => Record<string, string | null>;
45};
46
47/**
48 * Like `useState`, but the value is initialized from URL query params on mount
49 * and the URL is updated synchronously via `history.replaceState` whenever
50 * the setter is called.
51 *
52 * Returns `[value, setValue]` — a drop-in replacement for `useState`.
53 */
54export function useQueryState<T>(
55 config: QueryParam<T>,
56): [T, (value: T) => void] {
57 let searchParams = useSearchParams();
58
59 // Use a ref for toParams so the setter callback identity is stable
60 // even when config is an inline object literal.
61 let toParamsRef = useRef(config.toParams);
62 toParamsRef.current = config.toParams;
63
64 let [value, _setValue] = useState<T>(() =>
65 config.fromParams((key) => searchParams.get(key)),
66 );
67
68 let setValue = useCallback((next: T) => {
69 _setValue(next);
70 let updates = toParamsRef.current(next);
71 // Read directly from window.location so we always merge against
72 // the latest URL, even if multiple setters fire in the same frame.
73 let params = new URLSearchParams(window.location.search);
74 for (let [key, v] of Object.entries(updates)) {
75 if (v === null) {
76 params.delete(key);
77 } else {
78 params.set(key, v);
79 }
80 }
81 let qs = params.toString();
82 let url = qs
83 ? `${window.location.pathname}?${qs}`
84 : window.location.pathname;
85 window.history.replaceState(history.state, "", url);
86 }, []);
87
88 return [value, setValue];
89}