UNPKG

@diginet/use-reactive

Version:

A reactive state management hook for React.

512 lines (461 loc) 22.2 kB
import { useState, useRef, useEffect } from "react"; // Callback type for subscriber export type SC<T> = (this: T, state: T, key: string | number | Symbol, value: unknown, previous: unknown, read?: boolean) => void; // Subscribe function signature export type S<T> = (targets: () => unknown | unknown[], callback: SC<T>, recursive?: boolean | 'deep', onRead?: boolean) => () => void // Effect function type export type E<T> = (this: T, state: T, subscribe: S<T>, history: H<T>) => void | (() => void); // Options export interface RO<T> { init?: (this: T, state: T, subscribe: S<T>, history: H<T>) => void, effects?: Array<[E<T>, (this: T, state: T, subscribe: S<T>, history: H<T>) => unknown[]]>, historySettings?: HistorySettings, noUseState?: boolean } // Subscriber entry interface SE<T> { recording: boolean, onRead?: boolean, callback: SC<T>, targets: { obj: object, prop: keyof T }[] }; // History interface export interface H<T> { enable(enabled?: boolean, maxDepth?: number): HistorySettings; undo(index?: number): void; redo(all?: boolean): void; revert(index: number): void; snapshot(): string | null; restore(id: string | null): void; clear(): void; entries: HE<T>[]; }; // History entry export interface HE<T> { id: string; timestamp: number; obj: object; key: keyof T; previous: unknown; value: unknown; }; // History settings export interface HistorySettings { enabled?: boolean; maxDepth?: number }; /** * Map for storing data about each property * key: Property key * value: [updateFlag, childMap, propValue] * * The updateFlag is used to track changes in the property value done via the proxy (as opposed to from props). */ type PropertyMap = Map<string | number | symbol, [boolean, PropertyMap | undefined, any]>; // Utility function to compare two objects function isEqual(x: any, y: any): boolean { const ok = Object.keys, tx = typeof x, ty = typeof y; return x && y && tx === 'object' && tx === ty ? ( ok(x).length === ok(y).length && ok(x).every(key => isEqual(x[key], y[key])) ) : (x === y); } /** * Recursively synchronizes and updates a structure containing information about each property. * - Tracks state changes and updates properties accordingly. * - Supports nested objects, while avoiding functions and getters. * - Uses a WeakMap for tracking object property maps. * * @param obj - The target object whose properties are being tracked. * @param stateMapRef - A WeakMap that associates objects with their corresponding property maps. * @param stateMap - A Map storing metadata about properties, including modification status and last known value. * @param newObj - An optional new object to compare against and apply updates from. */ const syncState = <T extends object>( obj: T, stateMapRef: WeakMap<object, PropertyMap>, stateMap: PropertyMap, newObj?: T ): void => { for (const key of Object.keys(obj) as (keyof T)[]) { let descriptor = Object.getOwnPropertyDescriptor(obj, key); if (newObj) { const newDescriptor = Object.getOwnPropertyDescriptor(newObj, key); if (newDescriptor) { descriptor = newDescriptor; } } // Determine if the property is a function or getter const isFunction = descriptor?.value && typeof descriptor.value === "function"; const isGetter = descriptor?.get && typeof descriptor.get === "function"; if (isFunction || isGetter) { // Re-define getters and functions without modifications Object.defineProperty(obj, key, descriptor!); continue; } const value = obj[key]; // Handle primitive values and arrays if (typeof value !== "object" || Array.isArray(value)) { if (typeof value === "function") continue; // Redundant check for safety // Retrieve stored property metadata const [modifiedFlag, , lastPropValue] = stateMap.get(key) || [undefined, undefined, undefined]; const newValue = newObj ? newObj[key] : value; const propValueChanged = !isEqual(lastPropValue, newValue); // Update the object property if it has changed if (newObj && propValueChanged && obj[key] !== newValue) { obj[key] = newValue; } // Update state tracking map stateMap.set(key, [modifiedFlag ?? false, undefined, newValue]); } else { // Handle nested objects let childStateMap = stateMap.get(key)?.[1]; if (!childStateMap) { childStateMap = new Map(); stateMap.set(key, [false, childStateMap, value]); } // Track nested object in the WeakMap stateMapRef.set(value as T, childStateMap); // Recursively sync nested properties syncState(value as T, stateMapRef, childStateMap, newObj ? (newObj[key] as T) : undefined); } } }; /** * useReactive - A custom React hook that creates a reactive state object. * * This hook provides a proxy-based reactive state that triggers re-renders * when properties change. It also supports computed properties (getters), * hot module reloading (HMR) synchronization, and optional side effects. * * @param inputState - The initial state object. * @param effect - Optional effect function that runs when dependencies change. * @param deps - Dependencies array for triggering reactivity updates. * @returns A reactive proxy of the state object. */ export function useReactive<T extends object>( inputState: T, options?: RO<T> ): [T, S<T>, H<T>] { if (typeof window === "undefined") { throw new Error("useReactive should only be used in the browser"); } const reactiveStateRef = useRef(inputState); const [, setTriggerF] = useState(0); // State updater to trigger re-renders const setTrigger = (foo: (prev: number) => number) => { if (!options?.noUseState) { setTriggerF(foo) } }; const proxyRef = useRef<T>(null); const nestedproxyrefs = useRef<Map<object, T>>(new Map()); const stateMapRef = useRef<WeakMap<object, PropertyMap>>(new WeakMap()); const subscribersRef = useRef<Array<SE<T>>>([]); const historyRef = useRef<HE<T>[]>([]); const historySettingsRef = useRef<HistorySettings>({ enabled: false, maxDepth: 100 }); const redoStack = useRef<HE<T>[]>([]); if (options?.historySettings?.enabled) historySettingsRef.current.enabled = options.historySettings.enabled; if (options?.historySettings?.maxDepth) historySettingsRef.current.maxDepth = options.historySettings.maxDepth; let stateMap = stateMapRef.current.get(reactiveStateRef.current); if (!stateMap) { stateMap = new Map(); stateMapRef.current.set(reactiveStateRef.current, stateMap); } // Sync the current state with the input object syncState(reactiveStateRef.current, stateMapRef.current, stateMap!, inputState); // Subscribe to the state object and track changes function subscribe(targets: () => unknown | unknown[], callback: SC<T>, recursive?: boolean | 'deep', onRead?: boolean) { let result = () => { }; if (subscribersRef.current && targets) { // Create a new subscriber object, start recording target accesses const subscriber: SE<T> = { callback, recording: true, onRead, targets: [] }; // Add the subscriber to the list subscribersRef.current?.push(subscriber); // Get the target object const target = targets(); // Handle nested targets if (recursive === 'deep' || recursive === true) { // Iterate over all properties of the target object except functions and getters, also possibly handle nested objects function iterate(target: { [key: string]: unknown }) { for (const key of Object.keys(target)) { const descriptor = Object.getOwnPropertyDescriptor(target, key); const isFunction = descriptor?.value && typeof descriptor.value === "function"; const isGetter = descriptor?.get && typeof descriptor.get === "function"; if (isFunction || isGetter) { continue; } const value = target[key]; if (recursive === 'deep' && typeof value === "object" && !Array.isArray(value)) { iterate(value as { [key: string]: unknown }); } } } if (target && typeof target === 'object' && !Array.isArray(target)) { iterate(target as { [key: string]: unknown }); } if (target && typeof target === 'object' && Array.isArray(target)) { for (const value of target) { if (typeof value === 'object' && !Array.isArray(value)) { iterate(value as { [key: string]: unknown }); } } } } // Stop recording target accesses subscriber.recording = false; result = () => { const index = subscribersRef.current?.indexOf(subscriber); if (index !== undefined && index !== -1) { subscribersRef.current?.splice(index, 1); } }; } return result; } // History functions // Enable/disable history const enableHistory = (enabled?: boolean, maxDepth?: number) => { if (enabled !== undefined) { historySettingsRef.current.enabled = enabled; if (!enabled) { historyRef.current = []; redoStack.current = []; } } if (maxDepth !== undefined) historySettingsRef.current.maxDepth = maxDepth; return historySettingsRef.current; }; // Undo/redo functions // Undo the last change const undoLast = () => { if (historyRef.current.length === 0) return; const lastChange = historyRef.current.pop(); if (lastChange !== undefined) { if (redoStack.current.length >= (historySettingsRef.current.maxDepth ?? 100)) redoStack.current.shift(); redoStack.current.push({ id: lastChange.id, obj: lastChange.obj, key: lastChange.key, previous: lastChange.previous, value: lastChange.value, timestamp: lastChange.timestamp }); const savedHistoryEnabled = historySettingsRef.current.enabled; historySettingsRef.current.enabled = false; (lastChange.obj as any)[lastChange.key] = lastChange.previous; historySettingsRef.current.enabled = savedHistoryEnabled; } }; // Undo the last change or a specific change const undo = (index?: number) => { if (index !== undefined && (index < 0 || index >= historyRef.current.length)) return; if (index !== undefined) { while (historyRef.current.length > index) { undoLast(); } } else { undoLast(); } }; // Redo the last undone change (or all undone changes) const redo = (all?: boolean) => { if (redoStack.current.length === 0) return; do { const redoChange = redoStack.current.pop(); if (redoChange) { (redoChange.obj as any)[redoChange.key] = redoChange.value; } } while (all && redoStack.current.length > 0); }; // Revert to a specific change const revert = (index: number) => { if (index < 0 || index >= historyRef.current.length) return; const entry = historyRef.current[index]; if (entry) { const savedHistoryEnabled = historySettingsRef.current.enabled; historySettingsRef.current.enabled = false; (entry.obj as any)[entry.key] = entry.previous; historySettingsRef.current.enabled = savedHistoryEnabled; historyRef.current.splice(index, 1); } }; // Clear the history const clear = () => { historyRef.current = [] redoStack.current = [] setTrigger((prev) => prev + 1); } // Get the current snapshot const snapshot = () => { return historyRef.current.length > 0 ? historyRef.current[historyRef.current.length - 1].id : null; }; // Restore a specific snapshot const restore = (id: string | null) => { let index if (id === null) { index = 0; } else { index = historyRef.current.findIndex(entry => (entry.id === id)); } if (index < 0) return; redoStack.current = []; if (id !== null) { while (historyRef.current.length > index + 1) { undo(); } } else { while (historyRef.current.length > 0) { undo(); } } }; // History object const history = { enable: enableHistory, clear, undo, redo, revert, snapshot, restore, entries: historyRef.current } // Create a proxy for the state object if it doesn't exist if (!proxyRef.current) { const createReactiveProxy = (target: T): T => { const proxy = new Proxy(target, { get(obj, prop: string | symbol) { const key = prop as keyof T; if (subscribersRef.current) { for (const subscriber of subscribersRef.current) { if (subscriber.recording) { const exists = subscriber.targets.some(target => target.obj === obj && target.prop === key); if (!exists) { subscriber.targets.push({ obj, prop: key }); } } } } let value: any // Handle computed properties (getters) const descriptor = Object.getOwnPropertyDescriptor(obj, key); const isFunction = descriptor?.value && typeof descriptor.value === "function"; const isGetter = descriptor?.get && typeof descriptor.get === "function"; if (isGetter) { value = descriptor.get!.call(proxy); } else value = obj[key]; // Proxy arrays to trigger updates on mutating methods if (Array.isArray(value)) { return new Proxy(value, { get(arrTarget, arrProp) { const prevValue = [...arrTarget]; const arrValue = arrTarget[arrProp as any]; // If accessing a possibly mutating array method, return a wrapped function if (typeof arrValue === "function") { return (...args: any[]) => { const result = arrValue.apply(arrTarget, args); if (!isEqual(prevValue, arrTarget)) { const stateMap = stateMapRef.current?.get(obj); if (!stateMap?.has(prop as keyof T)) return false; const [, map, propValue] = stateMap.get(prop as keyof T)!; if (!isEqual(prevValue, arrTarget)) { stateMap.set(prop as keyof T, [true, map, propValue]); obj[prop as keyof T] = arrTarget as any; setTrigger((prev) => prev + 1); } } return result; }; } return arrValue; }, }); } // Wrap nested objects in proxies if (typeof value === "object" && value !== null && !Array.isArray(value)) { let result = nestedproxyrefs.current.get(value); if (!result) { result = createReactiveProxy(value as T); nestedproxyrefs.current.set(value, result); } return result; } // Ensure functions are bound to the proxy object if (isFunction) { return function (...args: any[]) { return value.apply(proxy, args); // Now correctly bound to the current proxy }; } if (subscribersRef.current) { for (const subscriber of subscribersRef.current) { if (subscriber.onRead && !subscriber.recording) { if (subscriber.targets.some(target => target.obj === obj && target.prop === prop)) { subscriber.callback.call(proxy, proxy, prop as keyof T, value, value, true); } } } } return value; }, set(obj, prop: string | symbol, value) { const stateMap = stateMapRef.current?.get(obj); if (!stateMap?.has(prop as keyof T)) return false; const [, map, propValue] = stateMap.get(prop as keyof T)!; const previousValue = obj[prop as keyof T]; if (!isEqual(previousValue, value)) { stateMap.set(prop as keyof T, [true, map, propValue]); obj[prop as keyof T] = value; if (historySettingsRef.current.enabled) { if (historyRef.current.length >= (historySettingsRef.current.maxDepth ?? 100)) historyRef.current.shift(); historyRef.current.push({ id: crypto.randomUUID(), obj: proxy, key: prop as keyof T, previous: previousValue, value, timestamp: Date.now() }); } setTrigger((prev) => prev + 1); } if (subscribersRef.current) { for (const subscriber of subscribersRef.current) { if (!subscriber.recording) { if (subscriber.targets.some(target => target.obj === obj && target.prop === prop)) { subscriber.callback.call(proxy, proxy, prop as keyof T, value, previousValue, false); } } } } return true; }, }); return proxy; } proxyRef.current = createReactiveProxy(reactiveStateRef.current); // If we have an init method, call it after creation if (options?.init && typeof options?.init === "function") { options.init.call(proxyRef.current, proxyRef.current, subscribe, history); } } // useEffect to handle side effects and cleanup if (options?.effects) { function getNestedValue<T>(obj: unknown, path?: string, defaultValue?: T): T | undefined { if (!obj || typeof obj !== 'object' || !path) return defaultValue; // Convert bracket notation to dot notation (e.g., "user.address[0].city" -> "user.address.0.city") const normalizedPath = path.replace(/\[(\d+)\]/g, '.$1'); return normalizedPath.split('.').reduce<any>((acc, key) => { if (acc === null || acc === undefined) return undefined; return acc[key]; }, obj) ?? defaultValue; } function getDeps(deps?: unknown[]): unknown[] | undefined { if (!deps) return undefined; return deps.map(prop => (typeof prop === 'symbol') ? getNestedValue(reactiveStateRef.current!, prop.description) : prop); } // Multiple effect/dependency pairs for (const [effectF, effectDeps] of options.effects) { useEffect(() => { let cleanup: (() => void) | void; if (effectF && proxyRef.current) { cleanup = effectF.apply(proxyRef.current, [proxyRef.current, subscribe, history]); } return () => { if (cleanup) cleanup(); }; }, effectDeps ? getDeps(effectDeps.call(reactiveStateRef.current, reactiveStateRef.current, subscribe, history)) : []); } } // Return the reactive proxy object etc return [proxyRef.current, subscribe, history] as const; }