UNPKG

@uistate/core

Version:

Lightweight event-driven state management with path-based subscriptions, wildcards, and async support

290 lines (257 loc) 8.55 kB
/** * EventState v5.6.2 - Optimized Path-Based State Management * * A lightweight, performant state management library using path-based subscriptions. * Optimized for selective notifications and granular updates. * * Features: * - Path-based get/set operations (e.g., 'user.profile.name') * - Selective subscriptions (only relevant subscribers fire) * - Wildcard subscriptions (e.g., 'user.*' catches all user changes) * - Global subscriptions (e.g., '*' catches all changes) * - Atomic batching (batch/setMany — subscribers fire after all writes) * - Zero dependencies * - ~2KB minified * * Performance characteristics: * - 2-9x faster than Zustand for selective subscriptions * - Competitive overall performance * - Minimal rendering overhead (1.27x faster paint times) * * @example * const store = createEventState({ count: 0, user: { name: 'Alice' } }); * * // Subscribe to specific path * const unsub = store.subscribe('count', (value) => { * console.log('Count changed:', value); * }); * * // Update state * store.set('count', 1); * * // Get state * const count = store.get('count'); * * // Wildcard subscription * store.subscribe('user.*', ({ path, value }) => { * console.log(`User field ${path} changed to:`, value); * }); * * // Global subscription * store.subscribe('*', ({ path, value }) => { * console.log(`State changed at ${path}:`, value); * }); * * // Batch multiple writes (subscribers fire once per path, after batch) * store.batch(() => { * store.set('user.name', 'Charlie'); * store.set('user.email', 'charlie@example.com'); * }); * * // Or use setMany for the same effect * store.setMany({ 'user.name': 'Charlie', 'user.email': 'charlie@example.com' }); * * // Cleanup * unsub(); * store.destroy(); */ export function createEventState(initial = {}) { const state = JSON.parse(JSON.stringify(initial)); const listeners = new Map(); const asyncOps = new Map(); let destroyed = false; // Batching: buffer writes and flush once at the end let batching = false; const batchBuffer = new Map(); function writeAndNotify(path, value) { const parts = path.split("."); const key = parts.pop(); let cur = state; for (const p of parts) { if (!cur[p]) cur[p] = {}; cur = cur[p]; } const oldValue = cur[key]; cur[key] = value; if (!destroyed) { const detail = { path, value, oldValue }; const exactListeners = listeners.get(path); if (exactListeners) { exactListeners.forEach(cb => cb(value, detail)); } if (parts.length) { let parent = ""; for (const p of parts) { parent = parent ? `${parent}.${p}` : p; const wildcardListeners = listeners.get(`${parent}.*`); if (wildcardListeners) { wildcardListeners.forEach(cb => cb(detail)); } } } const globalListeners = listeners.get('*'); if (globalListeners) { globalListeners.forEach(cb => cb(detail)); } } return value; } function flushBatch() { const entries = Array.from(batchBuffer.entries()); batchBuffer.clear(); for (const [p, v] of entries) { writeAndNotify(p, v); } } return { /** * Get value at path * @param {string} path - Dot-separated path (e.g., 'user.profile.name') * @returns {*} Value at path, or entire state if no path provided */ get(path) { if (destroyed) throw new Error('Cannot get from destroyed store'); if (!path) return state; const parts = path.split('.'); let cur = state; for (const p of parts) { if (cur == null) return undefined; cur = cur[p]; } return cur; }, /** * Set value at path and notify subscribers * @param {string} path - Dot-separated path (e.g., 'user.profile.name') * @param {*} value - New value * @returns {*} The value that was set */ set(path, value) { if (destroyed) throw new Error('Cannot set on destroyed store'); if (!path) return value; if (batching) { batchBuffer.set(path, value); return value; } return writeAndNotify(path, value); }, async setAsync(path, fetcher) { if (destroyed) throw new Error('Cannot setAsync on destroyed store'); if (!path) throw new TypeError('setAsync requires a path'); if (typeof fetcher !== 'function') { throw new TypeError('setAsync(path, fetcher) requires a function fetcher'); } if (asyncOps.has(path)) { asyncOps.get(path).controller.abort(); } const controller = new AbortController(); asyncOps.set(path, { controller }); try { this.batch(() => { this.set(`${path}.status`, 'loading'); this.set(`${path}.error`, null); }); const data = await fetcher(controller.signal); if (destroyed) throw new Error('Cannot setAsync on destroyed store'); this.batch(() => { this.set(`${path}.data`, data); this.set(`${path}.status`, 'success'); }); return data; } catch (err) { if (err?.name === 'AbortError') { this.set(`${path}.status`, 'cancelled'); const cancelErr = new Error('Request cancelled'); cancelErr.name = 'AbortError'; throw cancelErr; } this.batch(() => { this.set(`${path}.status`, 'error'); this.set(`${path}.error`, err?.message ?? String(err)); }); throw err; } finally { const op = asyncOps.get(path); if (op?.controller === controller) { asyncOps.delete(path); } } }, cancel(path) { if (destroyed) throw new Error('Cannot cancel on destroyed store'); if (!path) throw new TypeError('cancel requires a path'); if (asyncOps.has(path)) { asyncOps.get(path).controller.abort(); asyncOps.delete(path); this.set(`${path}.status`, 'cancelled'); } }, /** * Batch multiple set() calls. Subscribers fire once per unique path * after the batch completes, not during. Supports nesting. * @param {Function} fn - Function containing set() calls to batch */ batch(fn) { if (destroyed) throw new Error('Cannot batch on destroyed store'); if (typeof fn !== 'function') throw new TypeError('batch requires a function'); const wasBatching = batching; batching = true; try { fn(); } finally { batching = wasBatching; if (!batching) flushBatch(); } }, /** * Set multiple paths atomically. Equivalent to batch(() => { set(a); set(b); ... }). * Accepts a plain object, an array of [path, value] pairs, or a Map. * @param {Object|Array|Map} entries - Paths and values to set */ setMany(entries) { if (destroyed) throw new Error('Cannot setMany on destroyed store'); if (!entries) return; this.batch(() => { if (Array.isArray(entries)) { for (const [p, v] of entries) this.set(p, v); } else if (entries instanceof Map) { for (const [p, v] of entries.entries()) this.set(p, v); } else if (typeof entries === 'object') { for (const p of Object.keys(entries)) this.set(p, entries[p]); } }); }, /** * Subscribe to changes at path * @param {string} path - Path to subscribe to (supports wildcards: 'user.*', '*') * @param {Function} handler - Callback function. * - Exact path subscriptions: (value, meta) => void * - Wildcard/global subscriptions: (meta) => void * @returns {Function} Unsubscribe function */ subscribe(path, handler) { if (destroyed) throw new Error('Cannot subscribe to destroyed store'); if (!path || typeof handler !== 'function') { throw new TypeError('subscribe requires path and handler'); } if (!listeners.has(path)) { listeners.set(path, new Set()); } listeners.get(path).add(handler); return () => listeners.get(path)?.delete(handler); }, /** * Destroy store and clear all subscriptions */ destroy() { if (!destroyed) { destroyed = true; batchBuffer.clear(); asyncOps.forEach(({ controller }) => controller.abort()); asyncOps.clear(); listeners.clear(); } } }; } export default createEventState;