@bhammond/react-stateful
Version:
A Signal and Querystate backed React State management utility library.
237 lines (197 loc) • 6.5 kB
text/typescript
import { useState, useCallback, useEffect, useDebugValue, useRef } from 'react';
export interface URLParamsLike {
get(key: string): string | null;
}
export interface RecordParams {
[key: string]: string | string[] | undefined;
}
export type ParamsInput = URLParamsLike | RecordParams;
function isNumber(value: any): value is number {
return typeof value === 'number' && !isNaN(value);
}
function isBoolean(value: any): value is boolean {
return typeof value === 'boolean';
}
function isObject(value: any): value is object {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isArray(value: any): value is any[] {
return Array.isArray(value);
}
function parseValue<T>(value: string | null, defaultValue: T): T {
try {
if (value === null) return defaultValue;
const decoded = decodeURIComponent(value);
if (defaultValue !== undefined) {
if (isNumber(defaultValue)) {
const num = Number(decoded);
return isNaN(num) ? defaultValue : num as T;
}
if (isBoolean(defaultValue)) {
return (decoded === 'true' ? true : decoded === 'false' ? false : defaultValue) as T;
}
if (isObject(defaultValue) || isArray(defaultValue)) {
try {
const parsed = JSON.parse(decoded);
return typeof parsed === typeof defaultValue ? parsed : defaultValue;
} catch {
return defaultValue;
}
}
}
if (decoded === 'true') return true as T;
if (decoded === 'false') return false as T;
if (/^\d+$/.test(decoded)) {
const num = Number(decoded);
return isNaN(num) ? decoded as T : num as T;
}
if (decoded.length > 0 && (decoded[0] === '{' || decoded[0] === '[')) {
try {
return JSON.parse(decoded) as T;
} catch {
return decoded as T;
}
}
return decoded as T;
} catch {
return defaultValue;
}
}
function stringifyValue(value: any): string {
try {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
} catch {
return '';
}
}
function isURLParamsLike(params: ParamsInput): params is URLParamsLike {
return typeof (params as URLParamsLike).get === 'function';
}
function getParam(params: ParamsInput, key: string): string | null {
try {
if (isURLParamsLike(params)) {
return params.get(key);
}
const value = params[key];
if (Array.isArray(value)) return value[0] ?? null;
return value ?? null;
} catch {
return null;
}
}
class Signal<T> {
private listeners: Array<(value: T) => void> = [];
value: T;
constructor(value: T) {
this.value = value;
}
notify(newValue: T) {
if (this.value === newValue) return;
this.value = newValue;
this.listeners.forEach(listener => {
try {
listener(newValue);
} catch (e) {
console.error('Signal listener error:', e);
}
});
}
subscribe(fn: (value: T) => void) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter(f => f !== fn);
};
}
}
const store = new Map<string, Signal<any>>();
function getSignal<T>(key: string, value: T): Signal<T> {
let signal = store.get(key);
if (!signal) {
signal = new Signal(value);
store.set(key, signal);
}
return signal;
}
type SetStateAction<T> = T | ((prevState: T) => T);
function useQueryState<T extends string>(
name: string,
params: ParamsInput,
defaultValue: T
): [T, (newValue: SetStateAction<T>) => void] {
if (!name) {
console.error('useQueryState requires a name parameter');
name = 'unnamed';
}
const key = encodeURIComponent(name);
const paramValue = getParam(params, key);
const initialValue = parseValue<T>(paramValue, defaultValue);
const [value, setValue] = useState<T>(initialValue);
const timeoutRef = useRef<number | null>(null);
const lastSearchRef = useRef(typeof window !== 'undefined' ? window.location.search : '');
const signalRef = useRef(getSignal(key, initialValue));
const isUpdatingRef = useRef(false);
useEffect(() => {
return signalRef.current.subscribe((newValue: T) => {
if (!isUpdatingRef.current) {
setValue(newValue);
}
});
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
function handleUrlChange() {
try {
if (window.location.search === lastSearchRef.current) return;
if (isUpdatingRef.current) return;
lastSearchRef.current = window.location.search;
const params = new URLSearchParams(window.location.search);
const urlValue = parseValue<T>(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]);
const setQueryValue = useCallback((newValue: SetStateAction<T>) => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
try {
isUpdatingRef.current = true;
const nextValue = typeof newValue === 'function'
? (newValue as (prev: T) => T)(signalRef.current.value)
: newValue;
if (nextValue === signalRef.current.value) {
isUpdatingRef.current = false;
return;
}
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()}` : ''}`;
lastSearchRef.current = params.toString();
window.history.pushState({}, '', newUrl);
}
isUpdatingRef.current = false;
} catch (e) {
isUpdatingRef.current = false;
console.error('Error updating query state:', e);
}
}, 100);
}, [key]);
useDebugValue(value);
return [value, setQueryValue];
}
export default useQueryState;