@uistate/core 5.5.2

Path-based state management with wildcard subscriptions and async support. ~6 KB, zero dependencies.

Install

npm i @uistate/core

Quick Start

import { createEventState } from '@uistate/core';

const store = createEventState({ count: 0, user: { name: 'Alice' } });

// Subscribe to a path
const unsub = store.subscribe('count', (value) => {
  console.log('Count:', value);
});

// Update state
store.set('count', 1);       // logs "Count: 1"
store.get('count');           // 1

// Cleanup
unsub();

createEventState(initialState)

createEventState(initial?: object) → store

Creates a new reactive store. The initial state is deep-cloned.

const store = createEventState({ count: 0 });
const empty = createEventState(); // empty store, add paths later

store.get(path?)

store.get(path?: string) → any

Retrieve a value by dot-separated path. Returns the entire state if no path is provided. Returns undefined for non-existent paths (no errors).

store.get('user.name');     // 'Alice'
store.get('user');          // { name: 'Alice' }
store.get();                // entire state object
store.get('no.such.path');  // undefined

store.set(path, value)

store.set(path: string, value: any) → value

Set a value at a path and notify all matching subscribers. Creates intermediate objects if they don't exist. Returns the value that was set.

store.set('count', 42);
store.set('user.email', 'alice@example.com'); // creates 'email' key on user
store.set('deep.nested.path', true);          // creates deep, deep.nested

Notification order

When you call store.set('user.name', 'Bob'), subscribers fire in this order:

  1. Exact — subscribers on 'user.name'
  2. Wildcard — subscribers on 'user.*'
  3. Global — subscribers on '*'

store.subscribe(path, handler)

store.subscribe(path: string, handler: Function) → unsubscribe

Subscribe to changes at a path. Returns a function that removes the subscription.

Exact path subscribers

Handler receives (value, detail) where detail is { path, value, oldValue }.

store.subscribe('count', (value, { oldValue }) => {
  console.log(`${oldValue} → ${value}`);
});

Wildcard subscribers

Handler receives (detail) where detail is { path, value, oldValue }.

store.subscribe('user.*', ({ path, value }) => {
  console.log(`${path} changed to`, value);
});

// Fires when any direct child of 'user' changes:
store.set('user.name', 'Bob');   // fires
store.set('user.email', 'x');    // fires
store.set('count', 1);           // does NOT fire

Global subscribers

store.subscribe('*', ({ path, value }) => {
  console.log(`[state] ${path} =`, value);
});
// Fires on every set() call — useful for logging/telemetry

store.setAsync(path, fetcher)

store.setAsync(path: string, fetcher: (signal: AbortSignal) → Promise) → Promise

Run an async operation with automatic status tracking. Manages three sub-paths:

Sub-pathValues
${path}.status'loading''success' | 'error' | 'cancelled'
${path}.dataThe resolved value
${path}.errorError message string, or null
await store.setAsync('users', async (signal) => {
  const res = await fetch('/api/users', { signal });
  return res.json();
});

store.get('users.status'); // 'success'
store.get('users.data');   // [...]

If setAsync is called again on the same path while a previous call is still in-flight, the previous call is automatically aborted.

store.cancel(path)

store.cancel(path: string) → void

Abort an in-flight setAsync operation. Sets status to 'cancelled'.

store.setAsync('users', fetcher); // starts loading
store.cancel('users');            // aborts, status → 'cancelled'

store.destroy()

store.destroy() → void

Tear down the store. Aborts all in-flight async operations, clears all subscriptions. Any further calls to get, set, or subscribe will throw.

Query Client

A convenience wrapper around setAsync for data-fetching patterns. Uses the query.${key} namespace internally.

import { createEventState } from '@uistate/core';
import { createQueryClient } from '@uistate/core/query';

const store = createEventState();
const qc = createQueryClient(store);

qc.query(key, fetcher)

await qc.query('users', async (signal) => {
  const res = await fetch('/api/users', { signal });
  return res.json();
});

qc.subscribe(key, cb) / subscribeToStatus / subscribeToError

qc.subscribe('users', (data) => renderUsers(data));
qc.subscribeToStatus('users', (status) => showSpinner(status === 'loading'));
qc.subscribeToError('users', (err) => showError(err));

qc.getData / getStatus / getError

qc.getData('users');    // [...] or undefined
qc.getStatus('users');  // 'success' | 'loading' | 'error' | 'idle'
qc.getError('users');   // null or error message

qc.cancel(key) / qc.invalidate(key)

qc.cancel('users');     // abort in-flight request
qc.invalidate('users'); // reset data, status, error to null/idle

Wildcard Patterns

When store.set('user.profile.name', 'Bob') is called, these subscribers fire:

SubscriptionFires?Handler args
'user.profile.name'Yes(value, detail)
'user.profile.*'Yes(detail)
'user.*'Yes(detail)
'*'Yes(detail)
'user.profile'No
'user'No

Wildcards match on parent paths, not the exact path being set. This mirrors how DOM event bubbling works.

Async Patterns

Loading indicator

store.subscribe('users.status', (status) => {
  spinner.hidden = status !== 'loading';
});

store.setAsync('users', fetchUsers);

Race condition handling

// User types fast — each keystroke triggers a search
input.addEventListener('input', () => {
  // setAsync auto-aborts the previous in-flight request
  store.setAsync('search', (signal) =>
    fetch(`/api/search?q=${input.value}`, { signal }).then(r => r.json())
  );
});

Error handling

try {
  await store.setAsync('users', fetchUsers);
} catch (err) {
  if (err.name === 'AbortError') return; // cancelled, ignore
  console.error('Fetch failed:', err);
}
// Or subscribe to the error path:
store.subscribe('users.error', (msg) => {
  if (msg) showToast(`Error: ${msg}`);
});