@uistate/core
Version:
Lightweight event-driven state management with path-based subscriptions, wildcards, and async support
290 lines (257 loc) • 8.55 kB
JavaScript
/**
* 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;