@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
JavaScript
;
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