UNPKG

@diginet/use-reactive

Version:

A reactive state management hook for React.

765 lines (760 loc) 35.4 kB
import React, { useRef, useState, useEffect, createContext, useContext } 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. */ 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]; } /** * Creates a globally shared reactive state using React Context and useReactive. * * @param initialState The initial state object * @returns {ReactiveStoreProvider, useReactiveStore} Context provider and hook */ function createReactiveStore(initialState, options) { // Create the context and provider const ReactiveStoreContext = createContext(null); const ReactiveStoreProvider = ({ children }) => { // Create the reactive state const [state, subscribe, history] = useReactive(initialState, { ...options, noUseState: true }); // Return the provider return (React.createElement(ReactiveStoreContext.Provider, { value: [state, subscribe, history] }, children)); }; /** * Hook to access the reactive state and subscribe to changes. * * @returns {ReactiveStoreContext<T>} The reactive state and subscribe function */ const useReactiveStore = () => { const context = useContext(ReactiveStoreContext); if (!context) { throw new Error("useReactiveStore must be used within a ReactiveStoreProvider"); } const [, setTrigger] = React.useState(0); // Create a proxy to track the subscriptions const proxyProxy = useReactive(context[0], { noUseState: true }); // Track the subscriptions const subscriptionsRef = useRef(null); if (!subscriptionsRef.current) { subscriptionsRef.current = new WeakMap(); } const removerRef = useRef(null); if (!removerRef.current) { // Subscribe to the proxy to track the subscriptions removerRef.current = proxyProxy[1](() => proxyProxy[0], function (state, key, _value, _previous, read) { // Get the map of subscriptions for the state let map = subscriptionsRef.current.get(state); if (!map) { // Create a new map if it doesn't exist map = new Map(); subscriptionsRef.current.set(state, map); } // Check if the key is not already subscribed if (read && !map.has(key)) { // Subscribe to the key map.set(key, true); context[1](() => state[key], () => { setTrigger((prev) => prev + 1); }); } }, 'deep', true); } return [proxyProxy[0], context[1], context[2]]; }; return [ReactiveStoreProvider, useReactiveStore]; } // Example 1: Simple Counter const Counter = () => { const [state] = useReactive({ count: 0 }); return (React.createElement("div", null, React.createElement("h3", null, "Counter"), React.createElement("p", null, "Count: ", state.count), React.createElement("button", { onClick: () => state.count++ }, "Increment"), React.createElement("button", { onClick: () => state.count-- }, "Decrement"))); }; // Example 2: Computed Property const ComputedPropertyExample = () => { const [state] = useReactive({ count: 2, get double() { return this.count * 2; }, }); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Computed Property"), React.createElement("p", null, "Count: ", state.count), React.createElement("p", null, "Double: ", state.double), React.createElement("button", { onClick: () => state.count++ }, "Increment"))); }; // Example 3: Async State Update const AsyncExample = () => { const [state] = useReactive({ count: 0, async incrementAsync() { await new Promise((resolve) => setTimeout(resolve, 1000)); this.count++; }, }); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Async Update"), React.createElement("p", null, "Count: ", state.count), React.createElement("button", { onClick: () => state.incrementAsync() }, "Increment Async"))); }; // Example 4: Nested State const NestedStateExample = () => { const [state] = useReactive({ nested: { value: 10 }, }); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Nested State"), React.createElement("p", null, "Nested Value: ", state.nested.value), React.createElement("button", { onClick: () => state.nested.value++ }, "Increment Nested"))); }; // Example 5: Single Effect const SingleEffectExample = () => { const [state] = useReactive({ count: 0 }, { effects: [[ function () { console.log("Count changed:", this.count); }, function () { return [this.count]; } ]] }); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Single Effect"), React.createElement("p", null, "Count: ", state.count), React.createElement("button", { onClick: () => state.count++ }, "Increment"))); }; // Example 6: Multiple Effects const MultipleEffectsExample = () => { const [state] = useReactive({ count: 0, text: "Hello" }, { effects: [ [function () { console.log("Count changed:", this.count); }, () => []], [function () { console.log("Text changed:", this.text); }, () => []], ] }); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Multiple Effects"), React.createElement("p", null, "Count: ", state.count), React.createElement("p", null, "Text: ", state.text), React.createElement("button", { onClick: () => state.count++ }, "Increment Count"), React.createElement("button", { onClick: () => (state.text = "World") }, "Change Text"))); }; const ControlButtons = ({ onIncrement, onDecrement, incrementLabel, decrementLabel }) => (React.createElement("div", null, React.createElement("button", { onClick: onIncrement }, incrementLabel), onDecrement && React.createElement("button", { onClick: onDecrement }, decrementLabel))); const ReactiveChild = ({ initialCount }) => { const [state] = useReactive({ count: initialCount }, { init() { console.log("Count changed due to prop update:", this.count); }, }); return (React.createElement("div", null, React.createElement("h3", null, "Reactive Child"), React.createElement("p", null, "Count: ", state.count), React.createElement(ControlButtons, { onIncrement: () => state.count++, incrementLabel: "Increment" }))); }; // Example using ReactiveChild to test prop dependency const EffectDependencyExample = () => { const [state] = useReactive({ count: 0 }); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Effect Dependency Example"), React.createElement("p", null, "Parent Count: ", state.count), React.createElement(ControlButtons, { onIncrement: () => state.count++, incrementLabel: "Increment Parent" }), React.createElement(ReactiveChild, { initialCount: state.count }))); }; // Example using ReactiveChild to test prop dependency const ArrayExample = () => { const [state] = useReactive({ todos: ['hello'], addWorld() { this.todos = [...this.todos, ' world']; }, addExclamation() { this.todos.push('!'); } }); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Array Example"), React.createElement("p", null, "todos: ", state.todos.map(todo => todo)), React.createElement("button", { onClick: state.addWorld }, "Add world"), React.createElement("button", { onClick: state.addExclamation }, "Add !"))); }; const [ReactiveStoreProvider, useReactiveStore] = createReactiveStore({ counter: 0, user: { name: "John Doe", age: 30 }, }, { init(_state, _subscribe, history) { history.enable(true); } }); function ReactiveStoreUser() { const [store, _subscribe, history] = useReactiveStore(); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Reactive store user"), React.createElement("p", null, "Name: ", store.user.name, ", Age: ", store.user.age), React.createElement("button", { onClick: () => store.user.name = "Jane Doe" }, "Change name"), React.createElement("button", { onClick: () => store.user.age++ }, "Increment age"), React.createElement("button", { onClick: () => history.undo() }, "Undo"))); } function AnotherReactiveStoreUser() { const [store, _subscribe, history] = useReactiveStore(); return (React.createElement("div", null, React.createElement("h3", null, "Another Reactive store user"), React.createElement("p", null, "Name: ", store.user.name, ", Age: ", store.user.age), React.createElement("button", { onClick: () => store.user.name = "Jane Doe" }, "Change name"), React.createElement("button", { onClick: () => store.user.age++ }, "Increment age"), React.createElement("button", { onClick: () => history.redo() }, "Redo"))); } const SubscribedCounter = () => { const [items, setItems] = useState([]); const [state] = useReactive({ count: 0, count2: 0, }, { init(_state, subscribe) { this.count = 10; setItems(items => [...items, "SubscribedCounter initialized"]); subscribe(() => [this.count2, this.count], (_state, key, value, previous) => { setItems(items => [...items, `${key} changed from ${previous} to ${value}`]); }); } }); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Subscribed Counter"), React.createElement("p", null, "Count: ", state.count, " Count2: ", state.count2), React.createElement("button", { onClick: () => state.count++ }, "Increment"), React.createElement("button", { onClick: () => state.count-- }, "Decrement"), React.createElement("button", { onClick: () => state.count2++ }, "Increment 2"), React.createElement("button", { onClick: () => state.count2-- }, "Decrement 2"), React.createElement("p", null, "Items: ", React.createElement("button", { onClick: () => setItems([]) }, "Clear")), items.map((item, index) => (React.createElement("p", { key: index }, item))))); }; const ExampleComponent = () => { const [state, , history] = useReactive({ count: 0 }, { historySettings: { enabled: true } }); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "Count: ", state.count), React.createElement("button", { onClick: () => state.count++ }, "Increment"), React.createElement("button", { onClick: () => history.undo() }, "Undo"), React.createElement("button", { onClick: () => history.redo() }, "Redo"))); }; const CheckBox = ({ caption, checked, setChecked }) => { return (React.createElement("div", null, React.createElement("label", null, React.createElement("input", { type: "checkbox", checked: checked, onChange: (e) => setChecked(e.target.checked) }), caption))); }; const ReactiveHistoryExample = () => { const [state, , history] = useReactive({ checked: false, sub: { text1: "", text2: "" } }); const [historyEnabled, setHistoryEnabled] = useState(false); const [snapshot, setSnapshot] = useState(undefined); return (React.createElement("div", null, "_________________________________", React.createElement("h3", null, "History"), React.createElement(CheckBox, { caption: "Some boolean", checked: state.checked, setChecked: (checked) => state.checked = checked }), React.createElement("input", { type: "text", value: state.sub.text1, onChange: (e) => (state.sub.text1 = e.target.value) }), React.createElement("p", null), React.createElement("input", { type: "text", value: state.sub.text2, onChange: (e) => (state.sub.text2 = e.target.value) }), React.createElement("br", null), React.createElement(CheckBox, { caption: "Enable history", checked: historyEnabled, setChecked: (checked) => { setHistoryEnabled(checked); history.enable(checked); } }), React.createElement("br", null), React.createElement("button", { onClick: () => history.undo(), disabled: !historyEnabled }, "Undo"), React.createElement("button", { onClick: () => history.undo(0), disabled: !historyEnabled }, "Undo all"), React.createElement("button", { onClick: () => history.redo(), disabled: !historyEnabled }, "Redo"), React.createElement("button", { onClick: () => history.redo(true), disabled: !historyEnabled }, "Redo all"), React.createElement("button", { onClick: () => history.clear(), disabled: !historyEnabled }, "Clear"), React.createElement("p", null), React.createElement("button", { onClick: () => setSnapshot(history.snapshot()), disabled: !historyEnabled }, "Take snapshot"), React.createElement("button", { onClick: () => history.restore(snapshot), disabled: snapshot === undefined || !historyEnabled }, "Restore snapshot"), React.createElement("h4", null, "Changes:"), React.createElement("ul", { style: { minHeight: '800px', overflowY: 'scroll' } }, history.entries.map((entry, index) => (React.createElement("div", { key: index, style: { display: 'flex' } }, React.createElement("li", { key: entry.id }, "[", new Date(entry.timestamp).toLocaleTimeString(), "]\u00A0", entry.key, "\u00A0 \"", String(entry.previous), "\" \u2192 \"", String(entry.value), "\"\u00A0", React.createElement("button", { onClick: () => history.revert(index) }, "Revert"), "\u00A0", React.createElement("button", { onClick: () => history.undo(index) }, "Undo to here")))))))); }; // Super Component to Include All Examples const Examples = () => { return (React.createElement("div", null, React.createElement("h2", null, "useReactive Examples"), React.createElement(Counter, null), React.createElement(ComputedPropertyExample, null), React.createElement(AsyncExample, null), React.createElement(NestedStateExample, null), React.createElement(SingleEffectExample, null), React.createElement(EffectDependencyExample, null), React.createElement(ArrayExample, null), React.createElement(MultipleEffectsExample, null), React.createElement(ReactiveStoreProvider, null, React.createElement(ReactiveStoreUser, null), React.createElement(AnotherReactiveStoreUser, null)), React.createElement(SubscribedCounter, null), React.createElement(ExampleComponent, null), React.createElement(ReactiveHistoryExample, null))); }; export { Examples, SubscribedCounter, createReactiveStore, useReactive }; //# sourceMappingURL=index.esm.js.map