a tool for shared writing and social publishing
at refactor/domain-management 89 lines 3.3 kB view raw
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}