@diginet/use-reactive
Version:
A reactive state management hook for React.
418 lines • 19.3 kB
JavaScript
import { useState, useRef, useEffect } from "react";
;
;
;
;
// Utility function to compare two objects
function isEqual(x, y) {
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 = (obj, stateMapRef, stateMap, newObj) => {
for (const key of Object.keys(obj)) {
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, childStateMap);
// Recursively sync nested properties
syncState(value, stateMapRef, childStateMap, newObj ? newObj[key] : 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(inputState, options) {
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) => {
if (!options?.noUseState) {
setTriggerF(foo);
}
};
const proxyRef = useRef(null);
const nestedproxyrefs = useRef(new Map());
const stateMapRef = useRef(new WeakMap());
const subscribersRef = useRef([]);
const historyRef = useRef([]);
const historySettingsRef = useRef({ enabled: false, maxDepth: 100 });
const redoStack = useRef([]);
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, callback, recursive, onRead) {
let result = () => { };
if (subscribersRef.current && targets) {
// Create a new subscriber object, start recording target accesses
const subscriber = {
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) {
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);
}
}
}
if (target && typeof target === 'object' && !Array.isArray(target)) {
iterate(target);
}
if (target && typeof target === 'object' && Array.isArray(target)) {
for (const value of target) {
if (typeof value === 'object' && !Array.isArray(value)) {
iterate(value);
}
}
}
}
// 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, maxDepth) => {
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[lastChange.key] = lastChange.previous;
historySettingsRef.current.enabled = savedHistoryEnabled;
}
};
// Undo the last change or a specific change
const undo = (index) => {
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) => {
if (redoStack.current.length === 0)
return;
do {
const redoChange = redoStack.current.pop();
if (redoChange) {
redoChange.obj[redoChange.key] = redoChange.value;
}
} while (all && redoStack.current.length > 0);
};
// Revert to a specific change
const revert = (index) => {
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[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) => {
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) => {
const proxy = new Proxy(target, {
get(obj, prop) {
const key = prop;
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;
// 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];
// If accessing a possibly mutating array method, return a wrapped function
if (typeof arrValue === "function") {
return (...args) => {
const result = arrValue.apply(arrTarget, args);
if (!isEqual(prevValue, arrTarget)) {
const stateMap = stateMapRef.current?.get(obj);
if (!stateMap?.has(prop))
return false;
const [, map, propValue] = stateMap.get(prop);
if (!isEqual(prevValue, arrTarget)) {
stateMap.set(prop, [true, map, propValue]);
obj[prop] = arrTarget;
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);
nestedproxyrefs.current.set(value, result);
}
return result;
}
// Ensure functions are bound to the proxy object
if (isFunction) {
return function (...args) {
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, value, value, true);
}
}
}
}
return value;
},
set(obj, prop, value) {
const stateMap = stateMapRef.current?.get(obj);
if (!stateMap?.has(prop))
return false;
const [, map, propValue] = stateMap.get(prop);
const previousValue = obj[prop];
if (!isEqual(previousValue, value)) {
stateMap.set(prop, [true, map, propValue]);
obj[prop] = 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, 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, 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(obj, path, defaultValue) {
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((acc, key) => {
if (acc === null || acc === undefined)
return undefined;
return acc[key];
}, obj) ?? defaultValue;
}
function getDeps(deps) {
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;
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];
}
//# sourceMappingURL=useReactive.js.map