global-event-state
v1.1.3
Published
Lightweight global state manager using CustomEvent
Maintainers
Readme
global-event-state
Lightweight, framework-agnostic global event-driven state store built on top of the browser's CustomEvent API.
Features:
- Per-key + wildcard subscriptions
- Undo / Redo history
- Middleware:
before&after - Optional
localStoragepersistence - Isolated instances + lazy configurable singleton
- Tiny API surface
- Optional React hooks (no provider needed)
Installation
npm install global-event-state1. Initialization Patterns & Basic Usage
Define a schema interface for best TypeScript inference. You have three ways to work with state:
createState– create an isolated, fully typed instance (test-friendly, multiple stores)initGlobalState+getGlobalState– initialize and use a typed shared singletonglobalState– untyped lazy singleton (only if you don't care about types)
import { createState, initGlobalState, getGlobalState } from 'global-event-state';
// Shared schema (recommended to keep in one module and reuse via import)
interface AppState {
theme: 'light' | 'dark';
user: { id: number; name: string } | null;
loggedIn: boolean;
count?: number;
}
// (A) Typed global singleton (call ONCE at startup)
const gs = initGlobalState<AppState>({ persist: true, storageKey: 'my-app' });
// Anywhere else (later modules):
// import { getGlobalState } from 'global-event-state'; const gs = getGlobalState<AppState>();
gs.set('theme', 'dark'); // OK
// gs.set('theme', 'blue'); // TS error
gs.set('user', { id: 1, name: 'Alice' });
gs.setMany({ loggedIn: true, count: 2 });
console.log(gs.get('theme')); // 'dark' (type: 'light' | 'dark' | undefined)
console.log(gs.getAll()); // Partial<AppState>
// (B) Isolated instance (separate store)
const local = createState<AppState>({ persist: false, storageKey: 'local-scope' });
local.set('loggedIn', false);2. Subscriptions (Typed)
// Using the same instance created earlier in this module
// Single key subscription
const offTheme = gs.subscribe('theme', (value) => {
// value: 'light' | 'dark' | undefined
console.log('Theme updated →', value);
});
// All changes (delta only contains keys that changed)
const offAll = gs.subscribeAll((delta) => {
// delta: Partial<AppState>
console.log('Delta →', delta);
});
// Cleanup
offTheme();
offAll();3. Middleware (Typed)
useBefore / useAfter receive strongly typed key & value:
// Reuse your created instance
gs.useBefore(({ key, value }) => {
if (key === 'count' && typeof value === 'number' && value < 0) {
throw new Error('count must be >= 0');
}
});
gs.useAfter(({ key, value }) => {
console.log(`[after] ${String(key)} ->`, value);
});
gs.set('count', 5); // OK
// gs.set('count', -1); // Throws at runtime by middleware3.1 Middleware Abort / Mutation (no exceptions)
You can prevent a state update or mutate the value without throwing:
Return values for useBefore middleware:
| Return | Meaning |
| ----------------- | --------------------------------------------------------- |
| void / nothing | Continue normally |
| false | Abort the update (state not changed, after-middleware skipped) |
| { value: newVal } | Replace the value that will be written; continue |
Example:
// Prevent setting theme to 'dark'
gs.useBefore(({ key, value }) => {
if (key === 'theme' && value === 'dark') return false; // abort
});
// Normalize usernames
gs.useBefore(({ key, value }) => {
if (key === 'user' && value) {
return { value: { ...value, name: value.name.trim() } };
}
});
gs.set('theme', 'dark'); // aborted, state unchanged
gs.set('user', { id: 1, name: ' Alice ' }); // name -> 'Alice'Notes:
- Aborted updates do NOT push to history or emit events.
- Multiple mutation middlewares run in order; each sees the previous one's output.
aftermiddlewares still only run if the update wasn't aborted.
4. Undo / Redo
gs.set('count', 1);
gs.set('count', 2);
gs.undo(); // back to 1
gs.redo(); // forward to 25. Persistence
import { createState } from 'global-event-state';
const persistent = createState<AppState>({ persist: true, storageKey: 'session-state' });
// Reloads restore automatically (browser environment).6. Isolated Instances (Typed)
import { createState } from 'global-event-state';
const local = createState<AppState>({ persist: false, storageKey: 'isolated' });
local.set('theme', 'light');7. Singleton Options: Typed vs Untyped
Preferred (typed):
// bootstrap.ts
import { initGlobalState } from 'global-event-state';
interface AppState {
theme: 'light' | 'dark';
user: { id: number; name: string } | null;
loggedIn: boolean;
}
initGlobalState<AppState>({ persist: true });
// anywhere.ts
import { getGlobalState } from 'global-event-state';
const gs = getGlobalState<AppState>();
gs.set('theme', 'light');Fallback (untyped, dynamic use):
import { globalState } from 'global-event-state';
globalState.set('anything', 123); // no compile-time checksRe-initializing: calling initGlobalState again returns the existing instance (unless you pass force: true).
TypeScript Notes
- For a shared singleton WITH types, always use
initGlobalState<AppState>()once thengetGlobalState<AppState>()elsewhere. - Use
createState<AppState>()for multiple stores, tests, or scoping. - Avoid using
globalStateunless you intentionally want a dynamic, untyped bag. - Middleware generics infer the value type based on the
keyparameter. - Need to reset during tests? Call
initGlobalState<AppState>(opts, true)withforce: true.
API Overview
| Method | Description |
| ----------------------------------------- | ------------------------------------------------------------------------------- |
| createState<AppState>(opts) | Create isolated typed instance |
| initGlobalState<AppState>(opts, force?) | Initialize (or reuse) typed singleton |
| getGlobalState<AppState>() | Retrieve the singleton; lazily creates an untyped fallback if never initialized |
| globalState | Default untyped singleton instance |
| new GlobalState<AppState>(opts) | Same as createState but via class |
| set(key, value) | Set a value |
| setMany(object) | Batch set |
| get(key) | Get a value |
| getAll() | Snapshot copy |
| remove(key) | Delete a key |
| clear() | Remove all keys |
| subscribe(key, cb) | Listen to one key |
| subscribeAll(cb) | Listen to all changes |
| useBefore(fn) | Pre-mutation middleware |
| useAfter(fn) | Post-mutation middleware |
| removeMiddleware(type, fn) | Remove a registered middleware (used internally by React hook cleanup) |
| undo() / redo() | History navigation |
| React (subpath) useGlobalState(key) | React hook subscribe to single key |
| React useGlobalAll() | Full state snapshot (re-renders on any change) |
| React useGlobalSelector(fn) | Derived slice with equality check |
| React useSetGlobalState(key) | Setter only hook |
| React useGlobalHistory() | Undo/redo callbacks |
| React useGlobalMiddleware(type, fn) | Register before/after middleware with auto cleanup |
| React useBeforeMiddleware(fn) | Shorthand for useGlobalMiddleware('before', fn) |
| React useAfterMiddleware(fn) | Shorthand for useGlobalMiddleware('after', fn) |
React Integration (Optional)
Install React peer if not already present:
npm install reactImport hooks from the react subpath (React remains an optional peer dependency):
import { initGlobalState } from 'global-event-state';
import { useGlobalState, useGlobalAll, useGlobalSelector, useSetGlobalState, useGlobalHistory } from 'global-event-state/react';
interface AppState {
theme: 'light' | 'dark';
user: { id: number; name: string } | null;
loggedIn: boolean;
count: number;
}
// Initialize once (e.g. src/bootstrap.ts or top of App.tsx)
initGlobalState<AppState>({ persist: true, storageKey: 'app' });
function ThemeToggle() {
const [theme, setTheme] = useGlobalState<AppState, 'theme'>('theme');
return <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>Theme: {theme || 'unset'}</button>;
}
function UserName() {
// Derived selection – re-renders only when name changes
const name = useGlobalSelector<AppState, string | undefined>((s) => s.user?.name);
return <span>User: {name ?? 'Guest'}</span>;
}
function Increment() {
const setCount = useSetGlobalState<AppState, 'count'>('count');
const count = useGlobalSelector<AppState, number>((s) => s.count ?? 0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
function HistoryControls() {
const { undo, redo } = useGlobalHistory<AppState>();
return (
<div>
<button onClick={undo}>Undo</button>
<button onClick={redo}>Redo</button>
</div>
);
}
function DebugAll() {
const state = useGlobalAll<AppState>();
return <pre>{JSON.stringify(state, null, 2)}</pre>;
}
export function App() {
return (
<div>
<ThemeToggle />
<UserName />
<Increment />
<HistoryControls />
<DebugAll />
</div>
);
}Notes:
- Passing the store instance is optional; hooks default to the initialized singleton via
getGlobalState(). - For best type inference in hooks, either pass the store explicitly or annotate generics as shown.
- No React Context provider is required (but you can still wrap for scoping multiple stores if desired).
useGlobalSelectoraccepts an optional custom comparator(a, b) => booleanas a third argument.
SSR: Hooks use useSyncExternalStore, so server rendering works (state is read synchronously).
Avoiding Generics at Call Sites
If you don't want to annotate useGlobalState<AppState,'theme'>('theme') you can bind a store to a set of inferred hooks using the factory:
import { initGlobalState } from 'global-event-state';
import { createGlobalStateHooks } from 'global-event-state/react';
interface AppState {
theme: 'light' | 'dark';
count: number;
user: { id: number; name: string } | null;
}
const store = initGlobalState<AppState>();
// Create typed hooks once and reuse everywhere
export const gsHooks = createGlobalStateHooks(store);
// Usage (types inferred automatically):
const [theme, setTheme] = gsHooks.useGlobalState('theme'); // theme: 'light' | 'dark' | undefined
const [count, setCount] = gsHooks.useGlobalState('count'); // count: number | undefined
const name = gsHooks.useGlobalSelector((s) => s.user?.name); // string | undefined
const full = gsHooks.useGlobalAll(); // Partial<AppState>
const { undo, redo } = gsHooks.useGlobalHistory();
setCount((count ?? 0) + 1);Factory returned hooks (all already bound to the provided store):
| Factory Hook | Underlying Global Hook | Notes |
| --------------------------------- | ---------------------- | ----------------------------------------------- |
| useGlobalState(key) | useGlobalState | [value, setter] tuple |
| useSetGlobalState(key) | useSetGlobalState | Setter only (if you prefer write-only patterns) |
| useGlobalAll() | useGlobalAll | Snapshot of entire state |
| useGlobalSelector(fn, isEqual?) | useGlobalSelector | Derived slice w/ optional comparator |
| useGlobalHistory() | useGlobalHistory | Undo/redo callbacks |
| useBefore(fn) | useBeforeMiddleware | Register before middleware (auto cleanup) |
| useAfter(fn) | useAfterMiddleware | Register after middleware (auto cleanup) |
Tip: You can alias them locally if you want shorter names:
const { useGlobalState: useGS, useGlobalSelector: useSel } = gsHooks;Example: Putting It Together
import { createState } from 'global-event-state';
interface AppState {
theme: 'light' | 'dark';
user: { id: number };
loggedIn: boolean;
}
const gs = createState<AppState>({ persist: true });
gs.useAfter(({ key, value }) => console.log('Changed:', key, value));
gs.subscribeAll((delta) => console.log('Delta:', delta));
// React-specific example (inside a component) registering a middleware:
// import { useBeforeMiddleware } from 'global-event-state/react';
// useBeforeMiddleware(({ key, value }) => { console.log('About to set', key, value); });
gs.set('user', { id: 1 });
gs.set('theme', 'dark');
gs.undo();License
MIT
Testing
This project uses the Bun built-in test runner.
Commands:
bun test # run the full suite once
bun test --watch # watch modePublish workflow runs npm test via the prepublishOnly script which maps to bun test.
Examples
React example app included in examples/react demonstrating hooks + factory usage.
Run locally (from repo root):
# (1) Build library once so dist/ exists for local linking
npm run build
# (2) Install example dependencies
cd examples/react
npm install
# (3) Start dev server
npm run dev
# Open http://localhost:5173Any changes in the core library require re-running npm run build (or use npm run dev in root in another terminal for watch mode) so the example picks up fresh output.
