UNPKG

@gawryco/use-shareable-state

Version:

The tiny, typed React hook for URL query string state. Transform your components into shareable, bookmarkable experiences with zero boilerplate.

693 lines (690 loc) 22.5 kB
'use strict'; var react = require('react'); // src/useShareableState.ts function dispatchQSChange(detail) { if (!isBrowser()) return; try { window.dispatchEvent(new CustomEvent("qs:changed", { detail })); } catch { } } function isBrowser() { return typeof window !== "undefined" && typeof document !== "undefined"; } function safeGetSearchParams() { if (!isBrowser()) return null; try { return new URLSearchParams(window.location.search); } catch { return null; } } function applyUrl(params, action = "replace") { if (!isBrowser()) return; const url = new URL(window.location.href); url.search = params.toString(); if (action === "push") { window.history.pushState(null, "", url.toString()); } else { window.history.replaceState(null, "", url.toString()); } } function useQueryParam(key, defaultValue, parse, format, normalize, opts) { const initial = react.useMemo(() => defaultValue, []); const [state, setState] = react.useState(initial); const keyRef = react.useRef(key); keyRef.current = key; const actionRef = react.useRef(opts?.action ?? "replace"); actionRef.current = opts?.action ?? actionRef.current; const stateRef = react.useRef(state); react.useEffect(() => { stateRef.current = state; }, [state]); react.useEffect(() => { if (!isBrowser()) return; const params = safeGetSearchParams(); if (!params) return; const raw = params.get(keyRef.current); if (raw === null) { const normalized = initial === null ? null : normalize ? normalize(initial) : initial; if (normalized === null) { params.delete(keyRef.current); applyUrl(params, actionRef.current); setState(null); } else { const seeded = format(normalized); if (seeded === "") { params.delete(keyRef.current); } else { params.set(keyRef.current, seeded); } applyUrl(params, actionRef.current); setState(normalized); } return; } const parsed = parse(raw); const value = parsed !== null ? parsed : initial; if (value === null) { setState(null); } else { setState(normalize ? normalize(value) : value); } }, [initial]); react.useEffect(() => { if (!isBrowser()) return; const handler = () => { const params = safeGetSearchParams(); if (!params) return; const raw = params.get(keyRef.current); if (raw === null) return; const parsed = parse(raw); if (parsed !== null) { const next = normalize ? normalize(parsed) : parsed; const prev = stateRef.current; setState(next); dispatchQSChange({ key: keyRef.current, prev, next, params: Object.fromEntries(params.entries()), source: "popstate", ts: Date.now() }); } }; window.addEventListener("popstate", handler); return () => window.removeEventListener("popstate", handler); }, [parse]); const setBoth = react.useCallback( (value) => { setState((prev) => { const rawNext = typeof value === "function" ? value(prev) : value; const next = rawNext === null ? null : normalize ? normalize(rawNext) : rawNext; const params = safeGetSearchParams(); if (params) { const nextStr = format(next); const currentStr = params.get(keyRef.current); if (nextStr === "") { if (currentStr !== null) { params.delete(keyRef.current); applyUrl(params, actionRef.current); dispatchQSChange({ key: keyRef.current, prev, next, params: Object.fromEntries(params.entries()), source: "set", ts: Date.now() }); } } else if (currentStr !== nextStr) { params.set(keyRef.current, nextStr); applyUrl(params, actionRef.current); dispatchQSChange({ key: keyRef.current, prev, next, params: Object.fromEntries(params.entries()), source: "set", ts: Date.now() }); } } return next; }); }, [format] ); return [state, setBoth]; } function useQueryParamNonNull(key, defaultValue, parse, format, normalize, opts) { const initial = react.useMemo(() => defaultValue, []); const [state, setState] = react.useState(initial); const keyRef = react.useRef(key); keyRef.current = key; const actionRef = react.useRef(opts?.action ?? "replace"); actionRef.current = opts?.action ?? actionRef.current; const stateRef = react.useRef(state); react.useEffect(() => { stateRef.current = state; }, [state]); react.useEffect(() => { if (!isBrowser()) return; const params = safeGetSearchParams(); if (!params) return; const raw = params.get(keyRef.current); if (raw === null) { const normalized = normalize ? normalize(initial) : initial; const seeded = format(normalized); if (seeded !== "") { params.set(keyRef.current, seeded); applyUrl(params, actionRef.current); } setState(normalized); return; } const parsed = parse(raw); const value = parsed !== null ? parsed : initial; setState(normalize ? normalize(value) : value); }, [initial]); react.useEffect(() => { if (!isBrowser()) return; const handler = () => { const params = safeGetSearchParams(); if (!params) return; const raw = params.get(keyRef.current); if (raw === null) { const next2 = normalize ? normalize(initial) : initial; setState(next2); dispatchQSChange({ key: keyRef.current, prev: stateRef.current, next: next2, params: Object.fromEntries(params.entries()), source: "popstate", ts: Date.now() }); return; } const parsed = parse(raw); const next = parsed !== null ? normalize ? normalize(parsed) : parsed : normalize ? normalize(initial) : initial; if (next !== stateRef.current) { const prev = stateRef.current; setState(next); dispatchQSChange({ key: keyRef.current, prev, next, params: Object.fromEntries(params.entries()), source: "popstate", ts: Date.now() }); } }; window.addEventListener("popstate", handler); return () => window.removeEventListener("popstate", handler); }, [parse, initial]); const setBoth = react.useCallback( (value) => { setState((prev) => { const rawNext = typeof value === "function" ? value(prev) : value; const next = normalize ? normalize(rawNext) : rawNext; const params = safeGetSearchParams(); if (params) { const nextStr = format(next); const currentStr = params.get(keyRef.current); if (nextStr === "") { if (currentStr !== null) { params.delete(keyRef.current); applyUrl(params, actionRef.current); dispatchQSChange({ key: keyRef.current, prev, next, params: Object.fromEntries(params.entries()), source: "set", ts: Date.now() }); } } else if (currentStr !== nextStr) { params.set(keyRef.current, nextStr); applyUrl(params, actionRef.current); dispatchQSChange({ key: keyRef.current, prev, next, params: Object.fromEntries(params.entries()), source: "set", ts: Date.now() }); } } return next; }); }, [format] ); return [state, setBoth]; } function useShareableState(key) { const numberBuilder = Object.assign( (defaultValue, opts) => { return useQueryParamNonNull( key, defaultValue, (raw) => { const n = Number(raw); return isNaN(n) ? null : n; }, (v) => String(v), (v) => { let x = v; if (typeof opts?.min === "number") x = Math.max(opts.min, x); if (typeof opts?.max === "number") x = Math.min(opts.max, x); if (typeof opts?.step === "number" && isFinite(opts.step) && opts.step > 0) { const steps = Math.round(x / opts.step); x = steps * opts.step; } return x; }, opts?.action !== void 0 ? { action: opts.action } : void 0 ); }, { optional: (defaultValue = null, opts) => { return useQueryParam( key, defaultValue, (raw) => { const n = Number(raw); return isNaN(n) ? null : n; }, (v) => v === null ? "" : String(v), (v) => { if (v === null) return v; let x = v; if (typeof opts?.min === "number") x = Math.max(opts.min, x); if (typeof opts?.max === "number") x = Math.min(opts.max, x); if (typeof opts?.step === "number" && isFinite(opts.step) && opts.step > 0) { const steps = Math.round(x / opts.step); x = steps * opts.step; } return x; }, opts?.action !== void 0 ? { action: opts.action } : void 0 ); } } ); const stringBuilder = Object.assign( (defaultValue, opts) => { return useQueryParamNonNull( key, defaultValue, (raw) => raw, (v) => v, (v) => { let s = v; if (typeof opts?.maxLength === "number") s = s.slice(0, Math.max(0, opts.maxLength)); if (typeof opts?.minLength === "number" && s.length < opts.minLength) { s = s.padEnd(opts.minLength, " "); } return s; }, opts?.action !== void 0 ? { action: opts.action } : void 0 ); }, { optional: (defaultValue = null, opts) => { return useQueryParam( key, defaultValue, (raw) => raw, (v) => v === null ? "" : v, (v) => { if (v === null) return v; let s = v; if (typeof opts?.maxLength === "number") s = s.slice(0, Math.max(0, opts.maxLength)); if (typeof opts?.minLength === "number" && s.length < opts.minLength) { s = s.padEnd(opts.minLength, " "); } return s; }, opts?.action !== void 0 ? { action: opts.action } : void 0 ); } } ); const booleanBuilder = Object.assign( (defaultValue, opts) => { return useQueryParamNonNull( key, defaultValue, (raw) => { const norm = raw.trim().toLowerCase(); if (["1", "true", "t", "yes", "y"].includes(norm)) return true; if (["0", "false", "f", "no", "n"].includes(norm)) return false; return null; }, (v) => v ? "1" : "0", void 0, opts?.action !== void 0 ? { action: opts.action } : void 0 ); }, { optional: (defaultValue = null, opts) => { return useQueryParam( key, defaultValue, (raw) => { const norm = raw.trim().toLowerCase(); if (["1", "true", "t", "yes", "y"].includes(norm)) return true; if (["0", "false", "f", "no", "n"].includes(norm)) return false; return null; }, (v) => v === null ? "" : v ? "1" : "0", void 0, opts?.action !== void 0 ? { action: opts.action } : void 0 ); } } ); const dateBuilder = Object.assign( (defaultValue, opts) => { return useQueryParamNonNull( key, defaultValue, (raw) => { const d = new Date(raw); return isNaN(d.getTime()) ? null : d; }, (v) => { const yyyy = v.getUTCFullYear(); const mm = String(v.getUTCMonth() + 1).padStart(2, "0"); const dd = String(v.getUTCDate()).padStart(2, "0"); return `${yyyy}-${mm}-${dd}`; }, (v) => { let d = v; if (opts?.min && d < opts.min) d = opts.min; if (opts?.max && d > opts.max) d = opts.max; return d; }, opts?.action !== void 0 ? { action: opts.action } : void 0 ); }, { optional: (defaultValue = null, opts) => { return useQueryParam( key, defaultValue, (raw) => { const d = new Date(raw); return isNaN(d.getTime()) ? null : d; }, (v) => { if (v === null) return ""; const yyyy = v.getUTCFullYear(); const mm = String(v.getUTCMonth() + 1).padStart(2, "0"); const dd = String(v.getUTCDate()).padStart(2, "0"); return `${yyyy}-${mm}-${dd}`; }, (v) => { if (v === null) return v; let d = v; if (opts?.min && d < opts.min) d = opts.min; if (opts?.max && d > opts.max) d = opts.max; return d; }, opts?.action !== void 0 ? { action: opts.action } : void 0 ); } } ); return { /** * Number state builder. Use .number(defaultValue) for non-nullable or .number().optional() for nullable. * * @example * const [count, setCount] = useShareableState('count').number(0); // non-nullable * const [optional, setOptional] = useShareableState('opt').number().optional(); // nullable */ number: numberBuilder, /** * String state builder. Use .string(defaultValue) for non-nullable or .string().optional() for nullable. * * @example * const [name, setName] = useShareableState('name').string(''); // non-nullable * const [optional, setOptional] = useShareableState('opt').string().optional(); // nullable */ string(defaultValue, opts) { if (defaultValue !== void 0) { return useQueryParamNonNull( key, defaultValue, (raw) => raw, (v) => v, (v) => { let s = v; if (typeof opts?.maxLength === "number") s = s.slice(0, Math.max(0, opts.maxLength)); if (typeof opts?.minLength === "number" && s.length < opts.minLength) { s = s.padEnd(opts.minLength, " "); } return s; }, opts?.action !== void 0 ? { action: opts.action } : void 0 ); } return stringBuilder; }, /** * Boolean state builder. Use .boolean(defaultValue) for non-nullable or .boolean().optional() for nullable. * * @example * const [active, setActive] = useShareableState('active').boolean(false); // non-nullable * const [optional, setOptional] = useShareableState('opt').boolean().optional(); // nullable */ boolean: booleanBuilder, /** * Date state builder. Use .date(defaultValue) for non-nullable or .date().optional() for nullable. * * @example * const [start, setStart] = useShareableState('start').date(new Date()); // non-nullable * const [optional, setOptional] = useShareableState('opt').date().optional(); // nullable */ date(defaultValue, opts) { if (defaultValue !== void 0) { return useQueryParamNonNull( key, defaultValue, (raw) => { const d = new Date(raw); return isNaN(d.getTime()) ? null : d; }, (v) => { const yyyy = v.getUTCFullYear(); const mm = String(v.getUTCMonth() + 1).padStart(2, "0"); const dd = String(v.getUTCDate()).padStart(2, "0"); return `${yyyy}-${mm}-${dd}`; }, (v) => { let d = v; if (opts?.min && d < opts.min) d = opts.min; if (opts?.max && d > opts.max) d = opts.max; return d; }, opts?.action !== void 0 ? { action: opts.action } : void 0 ); } return dateBuilder; }, /** * Enum state builder. Binds a string literal union (enum-like) to a query param. * * @template U extends string * @example * type Theme = 'light' | 'dark'; * const [theme, setTheme] = useShareableState('theme').enum<Theme>(['light','dark'], 'light'); // non-nullable * const [optional, setOptional] = useShareableState('opt').enum<Theme>().optional(['light','dark']); // nullable */ enum(allowed, defaultValue, opts) { if (allowed !== void 0 && defaultValue !== void 0) { return useQueryParamNonNull( key, defaultValue, (raw) => allowed.includes(raw) ? raw : null, (v) => v, void 0, opts?.action !== void 0 ? { action: opts.action } : void 0 ); } const enumBuilder = Object.assign( (allowed2, defaultValue2, opts2) => { return useQueryParamNonNull( key, defaultValue2, (raw) => allowed2.includes(raw) ? raw : null, (v) => v, void 0, opts2?.action !== void 0 ? { action: opts2.action } : void 0 ); }, { optional: (allowed2, defaultValue2 = null, opts2) => { return useQueryParam( key, defaultValue2, (raw) => allowed2.includes(raw) ? raw : null, (v) => v === null ? "" : v, void 0, opts2?.action !== void 0 ? { action: opts2.action } : void 0 ); } } ); return enumBuilder; }, /** * Custom state builder. Provide your own parse/format functions. * * @template T * @example * const [ids, setIds] = useShareableState('ids').custom<number[]>([], parse, format); // non-nullable * const [optional, setOptional] = useShareableState('opt').custom<number[]>().optional(null, parse, format); // nullable */ custom() { const customBuilder = Object.assign( (defaultValue, parse, format, opts) => { return useQueryParamNonNull( key, defaultValue, parse, format, void 0, opts?.action !== void 0 ? { action: opts.action } : void 0 ); }, { optional: (defaultValue, parse, format, opts) => { return useQueryParam( key, defaultValue, parse, format, void 0, opts?.action !== void 0 ? { action: opts.action } : void 0 ); } } ); return customBuilder; }, /** * JSON state builder. Binds a JSON-serializable value to a query param. * * @template T * @example * const [data, setData] = useShareableState('data').json<{q: string}>({q: ''}); // non-nullable * const [optional, setOptional] = useShareableState('opt').json<{q: string}>().optional(); // nullable */ json(defaultValue, opts) { if (defaultValue !== void 0) { const parseJson = (raw) => { try { const parsed = opts?.parse ? opts.parse(raw) : JSON.parse(raw); if (opts?.validate) { return opts.validate(parsed) ? parsed : null; } return parsed; } catch { return null; } }; const formatJson = (value) => { if (opts?.omitEmpty && opts.omitEmpty(value)) return ""; try { return opts?.stringify ? opts.stringify(value) : JSON.stringify(value); } catch { return ""; } }; return useQueryParamNonNull( key, defaultValue, parseJson, formatJson, void 0, opts?.action !== void 0 ? { action: opts.action } : void 0 ); } const jsonBuilder = Object.assign( (defaultValue2, opts2) => { const parseJson = (raw) => { try { const parsed = opts2?.parse ? opts2.parse(raw) : JSON.parse(raw); if (opts2?.validate) { return opts2.validate(parsed) ? parsed : null; } return parsed; } catch { return null; } }; const formatJson = (value) => { if (opts2?.omitEmpty && opts2.omitEmpty(value)) return ""; try { return opts2?.stringify ? opts2.stringify(value) : JSON.stringify(value); } catch { return ""; } }; return useQueryParamNonNull( key, defaultValue2, parseJson, formatJson, void 0, opts2?.action !== void 0 ? { action: opts2.action } : void 0 ); }, { optional: (defaultValue2 = null, opts2) => { const parseJson = (raw) => { try { const parsed = opts2?.parse ? opts2.parse(raw) : JSON.parse(raw); if (opts2?.validate) { return opts2.validate(parsed) ? parsed : null; } return parsed; } catch { return null; } }; const formatJson = (value) => { if (value === null) return ""; if (opts2?.omitEmpty && opts2.omitEmpty(value)) return ""; try { return opts2?.stringify ? opts2.stringify(value) : JSON.stringify(value); } catch { return ""; } }; return useQueryParam( key, defaultValue2, parseJson, formatJson, void 0, opts2?.action !== void 0 ? { action: opts2.action } : void 0 ); } } ); return jsonBuilder; } }; } exports.useShareableState = useShareableState; //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map