@diginet/use-reactive
Version:
A reactive state management hook for React.
512 lines (461 loc) • 22.2 kB
text/typescript
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;
}