UNPKG

@bhammond/react-stateful

Version:

A Signal and Querystate backed React State management utility library.

237 lines (197 loc) 6.5 kB
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;