use-stateful-url
Version:
A React hook for managing state and synchronizing it with URL hash parameters. Intended Purpose: To allow for URLs to be shared and have pages display as intended by the function of state or allow changes in state to be navigable via forward and backward
472 lines (468 loc) • 20.4 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react')) :
typeof define === 'function' && define.amd ? define(['exports', 'react'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.useStatefulUrl = {}, global.React));
})(this, (function (exports, react) { 'use strict';
// Utility functions for common serialization patterns
const hashSerializers = {
/** Serialize arrays as comma-separated strings */
stringArray: {
serialize: (value) => value.join(","),
deserialize: (value) => (value ? value.split(",") : []),
},
/** Serialize Set as comma-separated strings */
stringSet: {
serialize: (value) => Array.from(value).join(","),
deserialize: (value) => new Set(value ? value.split(",") : []),
},
/** Serialize boolean values */
boolean: {
serialize: (value) => value.toString(),
deserialize: (value) => value === "true",
},
/** Serialize number values */
number: {
serialize: (value) => value.toString(),
deserialize: (value) => {
const num = Number(value);
return isNaN(num) ? 0 : num;
},
},
/** Serialize objects as JSON */
json: {
serialize: (value) => JSON.stringify(value),
deserialize: (value) => {
try {
return JSON.parse(value);
}
catch {
return null;
}
},
},
};
// Default serializer for simple objects
const defaultSerializer = {
serialize: (state) => {
const result = {};
for (const [key, value] of Object.entries(state)) {
if (value == null)
continue;
if (Array.isArray(value)) {
if (value.length > 0) {
result[key] = value.join(",");
}
}
else if (value instanceof Set) {
if (value.size > 0) {
result[key] = Array.from(value).join(",");
}
}
else if (typeof value === "object") {
result[key] = JSON.stringify(value);
}
else {
result[key] = String(value);
}
}
return result;
},
deserialize: (params, initialState) => {
const result = { ...initialState };
for (const [key, value] of Array.from(params.entries())) {
if (key in initialState) {
const initialValue = initialState[key];
if (Array.isArray(initialValue)) {
result[key] = value
? value.split(",")
: [];
}
else if (initialValue instanceof Set) {
result[key] = new Set(value ? value.split(",") : []);
}
else if (typeof initialValue === "boolean") {
result[key] = value === "true";
}
else if (typeof initialValue === "number") {
const num = Number(value);
result[key] = isNaN(num)
? initialValue
: num;
}
else if (typeof initialValue === "object" && initialValue !== null) {
try {
result[key] = JSON.parse(value);
}
catch {
result[key] = initialValue;
}
}
else {
result[key] = value;
}
}
}
return result;
},
};
// Utility functions
const isClient = () => typeof window !== "undefined";
// Default delimiters for isolating useStatefulUrl content
const DEFAULT_DELIMITERS = {
start: "__UHS-",
end: "-UHS__",
};
const extractStatefulHashParts = (hash, delimiters = DEFAULT_DELIMITERS) => {
const startDelim = delimiters.start;
const endDelim = delimiters.end;
const startIndex = hash.indexOf(startDelim);
const endIndex = hash.indexOf(endDelim);
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
return {
before: hash,
statefulHash: "",
after: "",
delimiters,
};
}
return {
before: hash.substring(0, startIndex),
statefulHash: hash.substring(startIndex + startDelim.length, endIndex),
after: hash.substring(endIndex + endDelim.length),
delimiters,
};
};
const parseStatefulHash = (delimiters = DEFAULT_DELIMITERS) => {
if (!isClient())
return new URLSearchParams();
const hash = window.location.hash.substring(1);
const { statefulHash } = extractStatefulHashParts(hash, delimiters);
return new URLSearchParams(statefulHash);
};
const updateStatefulHash = (params, usePushState = false, delimiters = DEFAULT_DELIMITERS, positionStrategy = "end") => {
if (!isClient())
return;
const currentHash = window.location.hash.substring(1);
const { before, after, statefulHash: existingState, } = extractStatefulHashParts(currentHash, delimiters);
const urlParams = new URLSearchParams();
// Only add non-empty values
for (const [key, value] of Object.entries(params)) {
if (value && value.length > 0) {
urlParams.set(key, value);
}
}
const stateContent = urlParams.toString();
// Reconstruct hash based on position strategy
let newHash = "";
if (stateContent) {
const delimitedContent = delimiters.start + stateContent + delimiters.end;
switch (positionStrategy) {
case "start":
newHash =
delimitedContent +
(before || after ? (before || "") + (after || "") : "");
break;
case "end":
const externalContent = (before || "") + (after || "");
newHash =
externalContent + (externalContent ? "" : "") + delimitedContent;
break;
case "preserve":
default:
// Preserve original position, or append to end if first time
if (existingState) {
// Replace in original position
newHash = before + delimitedContent + after;
}
else {
// First time: append to end
const externalContent = (before || "") + (after || "");
newHash =
externalContent + (externalContent ? "" : "") + delimitedContent;
}
break;
}
}
else {
// No state content, just return external content
newHash = (before || "") + (after || "");
}
const newUrl = newHash
? `#${newHash}`
: window.location.pathname + window.location.search;
if (usePushState) {
window.history.pushState(null, "", newUrl);
}
else {
window.history.replaceState(null, "", newUrl);
}
};
// Utility functions for hash manipulation
const hashUtils = {
/** Get the current hash without useStatefulUrl content */
getHashWithoutState: (delimiters = DEFAULT_DELIMITERS) => {
if (!isClient())
return "";
const hash = window.location.hash.substring(1);
const { before, after } = extractStatefulHashParts(hash, delimiters);
return before + after;
},
/** Get only the useStatefulUrl content from hash */
getStateFromHash: (delimiters = DEFAULT_DELIMITERS) => {
if (!isClient())
return "";
const hash = window.location.hash.substring(1);
const { statefulHash } = extractStatefulHashParts(hash, delimiters);
return statefulHash;
},
/** Check if hash contains useStatefulUrl content */
hasHashState: (delimiters = DEFAULT_DELIMITERS) => {
if (!isClient())
return false;
const hash = window.location.hash.substring(1);
return hash.includes(delimiters.start) && hash.includes(delimiters.end);
},
/** Safely update the non-useStatefulUrl portion of the hash */
setExternalHash: (newExternalHash, delimiters = [DEFAULT_DELIMITERS], usePushState = false) => {
if (!isClient())
return;
const statefulHashes = [];
const currentHash = window.location.hash.substring(1);
for (const delimiter of delimiters) {
const { statefulHash } = extractStatefulHashParts(currentHash, delimiter);
if (statefulHash) {
statefulHashes.push(delimiter.start + statefulHash + delimiter.end);
}
}
const statefulUrl = statefulHashes.join("");
let newHash = newExternalHash;
if (statefulUrl) {
// Add delimiter and useStatefulUrl content to the end for consistency
newHash += statefulUrl;
}
const newUrl = newHash
? `#${newHash}`
: window.location.pathname + window.location.search;
if (usePushState) {
window.history.pushState(null, "", newUrl);
}
else {
window.history.replaceState(null, "", newUrl);
}
// Trigger hashchange event for useStatefulUrl to pick up the change
window.dispatchEvent(new HashChangeEvent("hashchange"));
},
};
// Debounce utility
const useDebounce = (callback, delay) => {
const timeoutRef = react.useRef(undefined);
return react.useCallback((...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
}, [callback, delay]);
};
/**
* Custom hook for managing state synchronized with URL hash parameters
*
* @param initialState - Initial state object
* @param options - Configuration options
* @returns Object with state, setState, and utility functions
*/
function useStatefulUrl(initialState, options = {}) {
const { debounceMs = 100, usePushState = false, serializers, initializeOnMount = true, delimiters = DEFAULT_DELIMITERS, positionStrategy = "end", } = options;
// Ensure delimiters have default values for function calls - memoize to prevent re-renders
const resolvedDelimiters = react.useMemo(() => ({
start: delimiters.start || DEFAULT_DELIMITERS.start,
end: delimiters.end || DEFAULT_DELIMITERS.end,
}), [delimiters.start, delimiters.end]);
const [state, setStateInternal] = react.useState(initialState);
const [isInitialized, setIsInitialized] = react.useState(false);
const isInitializedRef = react.useRef(false);
// Extract serializer functions and their string representations for ESLint
const serializeFn = serializers?.serialize;
const deserializeFn = serializers?.deserialize;
const serializeString = serializeFn?.toString();
const deserializeString = deserializeFn?.toString();
// Memoize serializers to prevent infinite re-renders when defined inline
const memoizedSerializers = react.useMemo(() => {
if (!serializeFn && !deserializeFn)
return null;
// Create stable references for the serializer functions
return { serialize: serializeFn, deserialize: deserializeFn };
// preventing linter ignore for now - eslint-disable-next-line react-hooks/exhaustive-deps
}, [serializeString, deserializeString]);
// Use memoized serializers if provided, otherwise use default
const serialize = react.useMemo(() => {
return memoizedSerializers?.serialize || defaultSerializer.serialize;
}, [memoizedSerializers?.serialize]);
const deserialize = react.useMemo(() => {
return (memoizedSerializers?.deserialize ||
((params) => defaultSerializer.deserialize(params, initialState)));
}, [memoizedSerializers?.deserialize, initialState]);
// Debounced URL update function - memoize the callback to prevent infinite re-renders
const updateHashCallback = react.useCallback((newState) => {
if (!isInitializedRef.current)
return;
const serialized = serialize(newState);
updateStatefulHash(serialized, usePushState, resolvedDelimiters, positionStrategy);
}, [serialize, usePushState, resolvedDelimiters, positionStrategy]);
const debouncedUpdateHash = useDebounce(updateHashCallback, debounceMs);
// Initialize state from URL hash on mount
react.useEffect(() => {
if (!initializeOnMount || !isClient()) {
setIsInitialized(true);
isInitializedRef.current = true;
return;
}
const params = parseStatefulHash(resolvedDelimiters);
const initializedState = deserialize(params);
setStateInternal(initializedState);
setIsInitialized(true);
isInitializedRef.current = true;
}, [initializeOnMount, deserialize, resolvedDelimiters]);
// Update URL when state changes (only after initialization)
react.useEffect(() => {
if (!isInitialized)
return;
debouncedUpdateHash(state);
}, [state, isInitialized, debouncedUpdateHash]);
// Handle browser navigation
react.useEffect(() => {
if (!isClient() || !isInitialized)
return;
const handleHashChange = () => {
const params = parseStatefulHash(resolvedDelimiters);
const newState = deserialize(params);
setStateInternal(newState);
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [isInitialized, deserialize, resolvedDelimiters]);
// State setter function
const setState = react.useCallback((newState) => {
setStateInternal((prev) => {
const nextState = typeof newState === "function"
? newState(prev)
: newState;
return nextState;
});
}, []);
// Utility functions
const syncToUrl = react.useCallback(() => {
const serialized = serialize(state);
updateStatefulHash(serialized, usePushState, resolvedDelimiters, positionStrategy);
}, [state, serialize, usePushState, resolvedDelimiters, positionStrategy]);
const clearHash = react.useCallback(() => {
if (!isClient())
return;
// Clear only the useStatefulUrl portion, preserve existing hash content
const currentHash = window.location.hash.substring(1);
const { before, after } = extractStatefulHashParts(currentHash, resolvedDelimiters);
const newHash = before + after;
const newUrl = newHash
? `#${newHash}`
: window.location.pathname + window.location.search;
window.history.replaceState(null, "", newUrl);
setStateInternal(initialState);
}, [initialState, resolvedDelimiters]);
const getHashWithoutState = react.useCallback(() => {
return hashUtils.getHashWithoutState(resolvedDelimiters);
}, [resolvedDelimiters]);
const getStateFromHash = react.useCallback(() => {
return hashUtils.getStateFromHash(resolvedDelimiters);
}, [resolvedDelimiters]);
return {
state,
setState,
isInitialized,
syncToUrl,
clearHash,
getHashWithoutState,
getStateFromHash,
};
}
// Convenience hooks for common patterns
function useStatefulUrlArray(key, initialValue = [], validValues) {
const initialState = { [key]: initialValue };
const options = {
serializers: {
serialize: (state) => ({
[key]: state[key]?.length > 0 ? state[key].join(",") : "",
}),
deserialize: (params) => {
const value = params.get(key);
const array = value ? value.split(",") : [];
const filtered = validValues
? array.filter((item) => validValues.includes(item))
: array;
return { [key]: filtered };
},
},
};
const { state, setState, ...rest } = useStatefulUrl(initialState, options);
return {
value: state[key],
setValue: (newValue) => {
setState((prev) => ({
...prev,
[key]: typeof newValue === "function" ? newValue(prev[key]) : newValue,
}));
},
...rest,
};
}
function useStatefulUrlSet(key, initialValue = new Set(), validValues) {
const initialState = { [key]: initialValue };
const options = {
serializers: {
serialize: (state) => ({
[key]: state[key]?.size > 0 ? Array.from(state[key]).join(",") : "",
}),
deserialize: (params) => {
const value = params.get(key);
const array = value ? value.split(",") : [];
const filtered = validValues
? array.filter((item) => validValues.includes(item))
: array;
return { [key]: new Set(filtered) };
},
},
};
const { state, setState, ...rest } = useStatefulUrl(initialState, options);
return {
value: state[key],
setValue: (newValue) => {
setState((prev) => ({
...prev,
[key]: typeof newValue === "function" ? newValue(prev[key]) : newValue,
}));
},
...rest,
};
}
function useStatefulUrlString(key, initialValue = "") {
const initialState = { [key]: initialValue };
const { state, setState, ...rest } = useStatefulUrl(initialState);
return {
value: state[key],
setValue: (newValue) => {
setState((prev) => ({
...prev,
[key]: typeof newValue === "function" ? newValue(prev[key]) : newValue,
}));
},
...rest,
};
}
exports.hashSerializers = hashSerializers;
exports.hashUtils = hashUtils;
exports.useStatefulUrl = useStatefulUrl;
exports.useStatefulUrlArray = useStatefulUrlArray;
exports.useStatefulUrlSet = useStatefulUrlSet;
exports.useStatefulUrlString = useStatefulUrlString;
}));
//# sourceMappingURL=useStatefulUrl.umd.js.map