@bhammond/react-stateful
Version:
A Signal and Querystate backed React State management utility library.
202 lines (201 loc) • 6.51 kB
JavaScript
import { useState, useCallback, useEffect, useDebugValue, useRef } from 'react';
function isNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
function isBoolean(value) {
return typeof value === 'boolean';
}
function isObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isArray(value) {
return Array.isArray(value);
}
function parseValue(value, defaultValue) {
try {
if (value === null)
return defaultValue;
const decoded = decodeURIComponent(value);
// Handle different expected types based on defaultValue or type T
if (defaultValue !== undefined) {
if (isNumber(defaultValue)) {
const num = Number(decoded);
return isNaN(num) ? defaultValue : num;
}
if (isBoolean(defaultValue)) {
return (decoded === 'true' ? true : decoded === 'false' ? false : defaultValue);
}
if (isObject(defaultValue) || isArray(defaultValue)) {
try {
const parsed = JSON.parse(decoded);
return typeof parsed === typeof defaultValue ? parsed : defaultValue;
}
catch (_a) {
return defaultValue;
}
}
}
// Type inference without defaultValue
if (decoded === 'true')
return true;
if (decoded === 'false')
return false;
if (/^\d+$/.test(decoded)) {
const num = Number(decoded);
return isNaN(num) ? decoded : num;
}
if (decoded.length > 0 && (decoded[0] == '{' || decoded[0] == '[')) {
try {
return JSON.parse(decoded);
}
catch (_b) {
return decoded;
}
}
return decoded;
}
catch (_c) {
return defaultValue;
}
}
function stringifyValue(value) {
try {
if (value === null || value === undefined)
return '';
if (typeof value === 'object')
return JSON.stringify(value);
return String(value);
}
catch (_a) {
return '';
}
}
function isURLParamsLike(params) {
return typeof params.get === 'function';
}
function getParam(params, key) {
var _a;
try {
if (isURLParamsLike(params)) {
return params.get(key);
}
const value = params[key];
if (Array.isArray(value))
return (_a = value[0]) !== null && _a !== void 0 ? _a : null;
return value !== null && value !== void 0 ? value : null;
}
catch (_b) {
return null;
}
}
class Signal {
constructor(value) {
this.listeners = [];
this.value = value;
}
notify(newValue) {
this.value = newValue;
this.listeners.forEach(listener => {
try {
listener(newValue);
}
catch (e) {
console.error('Signal listener error:', e);
}
});
}
subscribe(fn) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter(f => f !== fn);
};
}
}
const store = new Map();
function getSignal(key, value) {
let signal = store.get(key);
if (!signal) {
signal = new Signal(value);
store.set(key, signal);
}
return signal;
}
function useQueryState(name, params, defaultValue) {
if (!name) {
console.error('useQueryState requires a name parameter');
name = 'unnamed';
}
const key = encodeURIComponent(name);
const paramValue = getParam(params, key);
const initialValue = parseValue(paramValue, defaultValue);
const [isInitialRender, setIsInitialRender] = useState(true);
const signalRef = useRef(getSignal(key, initialValue));
const [value, setValue] = useState(initialValue);
useEffect(() => {
setIsInitialRender(false);
}, []);
useEffect(() => {
let mounted = true;
if (!isInitialRender && typeof window !== 'undefined') {
try {
const params = new URLSearchParams(window.location.search);
const urlValue = parseValue(params.get(key), defaultValue);
if (mounted) {
signalRef.current.notify(urlValue);
}
}
catch (e) {
console.error('Error syncing with URL:', e);
}
}
return () => {
mounted = false;
};
}, [isInitialRender, key, defaultValue]);
useEffect(() => {
return signalRef.current.subscribe(setValue);
}, []);
const setQueryValue = useCallback((newValue) => {
try {
const nextValue = typeof newValue === 'function'
? newValue(signalRef.current.value)
: newValue;
signalRef.current.notify(nextValue);
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
if (nextValue === null || nextValue === false || nextValue === '') {
params.delete(key);
}
else {
params.set(key, encodeURIComponent(stringifyValue(nextValue)));
}
const newUrl = `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}`;
window.history.pushState({}, '', newUrl);
}
}
catch (e) {
console.error('Error updating query state:', e);
}
}, [key]);
useEffect(() => {
if (typeof window === 'undefined')
return;
function handleUrlChange() {
try {
const params = new URLSearchParams(window.location.search);
const urlValue = parseValue(params.get(key), defaultValue);
signalRef.current.notify(urlValue);
}
catch (e) {
console.error('Error handling URL change:', e);
}
}
window.addEventListener('popstate', handleUrlChange);
return () => {
window.removeEventListener('popstate', handleUrlChange);
};
}, [key, defaultValue]);
useDebugValue(value);
return [isInitialRender ? initialValue : value, setQueryValue];
}
export default useQueryState;