Reactive state management with a plugin architecture, signal-inspired computed properties, and a headless core that runs anywhere JavaScript runs.
- Framework-Agnostic — Works with vanilla JavaScript, React, Vue, Lit, Svelte, or server-side code
- Headless Core —
CPXStoreCoreruns in Node, Deno, Bun, and Cloudflare Workers with no DOM - Plugin Architecture — Opt into middleware, history, persistence, and collaboration — or use none
- Signal-Inspired Reactivity — Auto-tracked computed properties with no dependency arrays
- Microtask-Coalesced Events — Multiple mutations in one tick produce one event
- Batching and Transactions —
batch()for explicit grouping,transaction()for rollback on error - Nested State — Deep object access via recursive Proxies with dot-path change tracking
- History Strategies — Per-property undo/redo with snapshot, patch, or none strategies
- Persistent Storage — Optional localStorage with cross-tab sync
- Pluggable Collaboration — BroadcastChannel, WebSocket, SSE, and Solid transports with conflict resolution
- Zero Dependencies — ~1,040 lines of TypeScript total
# Using JSR (recommended)
deno add jsr:@chapeaux/cpx-store
# Using npm
npm install @chapeaux/cpx-storeimport { CPXStore } from '@chapeaux/cpx-store';
import { historyPlugin } from '@chapeaux/cpx-store/plugins/history';
class AppStore extends CPXStore {
constructor() {
super({ count: 0 }, historyPlugin());
}
}
customElements.define('app-store', AppStore);<app-store id="store"></app-store>
<script>
const store = document.querySelector('#store');
store.addEventListener('change', (e) => {
const { changes } = e.detail;
if (changes.count)
console.log(`count: ${changes.count.old} → ${changes.count.val}`);
});
store.state.count++;
store.undo();
</script>import { CPXStoreCore } from '@chapeaux/cpx-store/cpx-store-core';
import { historyPlugin } from '@chapeaux/cpx-store/plugins/history';
const store = new CPXStoreCore({ count: 0 }, historyPlugin());
store.computed('doubled', () => (store.state.count as number) * 2);
store.state.count = 5;
console.log(store.state.doubled); // 10
store.undo();
console.log(store.state.count); // 0CPXStoreCore initializes immediately in the constructor. No DOM, no connectedCallback, no customElements.define. Same plugins, same API.
// Browser
new CPXStore(initialState, ...plugins)
// Headless
new CPXStoreCore(initialState, ...plugins)initialState— Plain object with initial values...plugins— Zero or moreStorePlugininstances
Runs before each mutation. Can throw to cancel.
import { middlewarePlugin } from '@chapeaux/cpx-store/plugins/middleware';
middlewarePlugin([
// Bare function — runs on every mutation
(prop, val, oldVal) => console.log(`${prop}: ${oldVal} → ${val}`),
// Filtered — only runs for matching properties
{ filter: /^editor\./, fn: (prop, val) => validate(val) },
{ filter: 'count', fn: (prop, val) => { if (val < 0) throw new Error('negative'); } },
])Filters accept a string (exact match or prefix), a RegExp, or a predicate function.
Undo/redo with configurable per-property strategies.
import { historyPlugin } from '@chapeaux/cpx-store/plugins/history';
historyPlugin({
maxHistory: 100, // default 100
defaultStrategy: 'snapshot', // default
strategies: {
content: 'patch', // store text diffs, not full copies
cursor: 'none', // exclude from history entirely
},
checkpointInterval: 20, // full snapshot every 20 patch ops
})| Strategy | Stores | Use Case |
|---|---|---|
snapshot |
Full old + new values | Small values (default) |
patch |
Text diffs or JSON Patch ops | Large strings, objects |
none |
Nothing | Cursor position, scroll offset |
Methods added to the store by this plugin: undo(), redo(), historyStrategy(prop, strategy), checkpoint(), clearHistory().
localStorage save/restore with cross-tab sync via storage events.
import { persistencePlugin } from '@chapeaux/cpx-store/plugins/persistence';
// Browser — reads key from the persist HTML attribute
persistencePlugin()
// Headless or explicit key
persistencePlugin({ key: 'my-app-state' })<app-store persist="my-app-state"></app-store>The plugin restores state from localStorage on init, writes once per flush (not per mutation), and listens for storage events from other tabs. In environments without localStorage, storage operations are silently skipped.
Pluggable sync transport with operation log and conflict resolution.
import { collabPlugin } from '@chapeaux/cpx-store/plugins/collab';
import { BroadcastChannelTransport } from '@chapeaux/cpx-store/transports/broadcast-channel';
import { WebSocketTransport } from '@chapeaux/cpx-store/transports/websocket';
import { SSETransport } from '@chapeaux/cpx-store/transports/sse';
import { SolidTransport } from '@chapeaux/cpx-store/transports/solid';
// Same-origin tab sync
collabPlugin({ transport: new BroadcastChannelTransport('my-channel') })
// Multi-user sync with automatic reconnection
collabPlugin({ transport: new WebSocketTransport('wss://example.com/sync') })
// Server-sent events (receive via EventSource, send via POST)
collabPlugin({
transport: new SSETransport('/api/events', { apiUrl: '/api/state' })
})
// Receive-only SSE (no outbound POST)
collabPlugin({ transport: new SSETransport('/api/events') })
// Decentralized sync via a Solid pod
collabPlugin({
transport: new SolidTransport('https://pod.example.org/apps/state.json', {
fetch: authenticatedFetch, // from @inrupt/solid-client-authn-browser or similar
})
})
// Custom conflict resolution
collabPlugin({
transport: new WebSocketTransport('wss://example.com/sync'),
resolver: {
resolve(local, remote) {
return remote.timestamp >= local.timestamp ? remote : local;
}
}
})Methods added: getOperationLog(), disconnect().
No dependency array — dependencies are auto-tracked during evaluation.
store.computed('total', () => {
return (store.state.price as number) * (store.state.qty as number);
});
store.state.price = 5;
console.log(store.state.total); // recomputed automaticallyConditional dependencies work correctly:
store.computed('value', () => {
return store.state.useA ? store.state.a : store.state.b;
});
// Only tracks state.a when state.useA is trueTransitive dependencies chain automatically:
store.computed('doubled', () => (store.state.base as number) * 2);
store.computed('quadrupled', () => (store.state.doubled as number) * 2);Multiple mutations in the same tick are coalesced into one event by default. For explicit control:
// Synchronous flush at end of block
store.batch(() => {
store.state.a = 1;
store.state.b = 2;
}); // one event
// Rollback on error
store.transaction(() => {
store.state.balance -= 100;
if (store.state.balance < 0) throw new Error('insufficient');
}); // state unchanged, no event
// Async with auto-batching
await store.dispatch(async (state) => {
const data = await fetch('/api');
state.items = await data.json();
state.loading = false;
}); // one event after promise resolvesDeep object properties are accessible through recursive Proxies:
const store = new CPXStoreCore({
editor: {
file1: { content: 'hello', dirty: false },
file2: { content: 'world', dirty: true },
}
});
store.state.editor.file1.content = 'updated';
// Change tracked as prop: "editor.file1.content"Each nested path gets its own reactive signal, so computed values that read state.editor.file1.content are not invalidated when file2 changes.
store.addEventListener('change', (e) => {
const { changes } = e.detail;
// changes is an object: { propName: { old, val }, ... }
for (const [prop, { old, val }] of Object.entries(changes)) {
console.log(`${prop}: ${old} → ${val}`);
}
});A global app-state-update event is also dispatched on window with { store: tagName, changes }.
const unsub = store.onChange((changes) => {
// changes is a Map<string, { old, val }>
for (const [prop, { old, val }] of changes) {
console.log(`${prop}: ${old} → ${val}`);
}
});
// Later:
unsub();Apply remote state without triggering outbound sync:
store.sync({ count: 42, theme: 'dark' });Override onSyncReceived for side effects:
class MyStore extends CPXStore {
onSyncReceived(newState, oldState) {
if (newState.theme !== oldState.theme) {
document.body.className = newState.theme;
}
}
}// Server
import { CPXStoreCore } from '@chapeaux/cpx-store/cpx-store-core';
const store = new CPXStoreCore({ user: null, items: [] });
store.state.user = await db.getUser(sessionId);
store.state.items = await db.getItems(store.state.user.id);
const html = `<script>window.__STATE__ = ${JSON.stringify(store.toJSON())}</script>`;// Client
import { CPXStore } from '@chapeaux/cpx-store';
import { historyPlugin } from '@chapeaux/cpx-store/plugins/history';
class MyStore extends CPXStore {
constructor() {
super(window.__STATE__, historyPlugin());
}
}
customElements.define('my-store', MyStore);The nested proxy system creates a ReactiveState signal and a cached Proxy for every path segment accessed. For moderate-cardinality application state — open tabs, selections, theme, layout — this is the right model. For large collections (10K+ node file trees, streaming language server diagnostics, terminal buffers), the per-node overhead becomes the bottleneck.
The recommended pattern: store a version counter in the proxied state, and keep the heavy data structure outside the proxy. Computed values and change handlers react to the counter bump, then read from the external structure directly.
class IDEStore extends CPXStore {
fileTree = new FileTree();
diagnostics = new Map();
constructor() {
super(
{
fileTreeVersion: 0,
diagnosticVersion: 0,
selectedFile: null,
openTabs: [],
theme: 'dark',
},
historyPlugin({
strategies: {
fileTreeVersion: 'none',
diagnosticVersion: 'none',
}
}),
persistencePlugin()
);
}
// One proxy write per batch of tree changes
updateFileTree(changes) {
this.fileTree.applyBatch(changes);
this.state.fileTreeVersion++;
}
// One proxy write per diagnostic update
setDiagnostics(uri, entries) {
this.diagnostics.set(uri, entries);
this.state.diagnosticVersion++;
}
}Computed values that depend on the version counter re-evaluate when it bumps:
store.computed('errorCount', () => {
store.state.diagnosticVersion; // subscribe to changes
let count = 0;
for (const entries of store.diagnostics.values()) {
count += entries.filter(d => d.severity === 'error').length;
}
return count;
});The store manages coordination state (what is selected, what is open, what version are we on). The heavy data lives in purpose-built structures that are optimized for their specific access patterns. Change events tell the UI that something changed; the UI reads the external structure to find out what.
This mirrors how production applications use Redux or Zustand: you store IDs and metadata in the state tree, not the full dataset. The difference is that cpx-store makes this explicit through the version-counter pattern rather than hiding it behind normalization libraries.
cpx-store/
├── src/
│ ├── cpx-store-core.ts # Headless core (CPXStoreCoreMixin + CPXStoreCore)
│ ├── cpx-store.ts # Web Component wrapper (CPXStore)
│ ├── reactivity.ts # ReactiveState, ReactiveComputed
│ ├── types.ts # StorePlugin, SyncTransport, StateOperation
│ ├── plugins/
│ │ ├── middleware.ts # Filterable middleware
│ │ ├── history.ts # Undo/redo with strategies
│ │ ├── persistence.ts # localStorage + cross-tab sync
│ │ └── collab.ts # Collaboration transport
│ ├── transports/
│ │ ├── broadcast-channel.ts # BroadcastChannel transport
│ │ ├── websocket.ts # WebSocket transport with reconnection
│ │ ├── sse.ts # SSE transport with reconnection
│ │ └── solid.ts # Solid pod transport with Notifications
│ ├── utils/
│ │ ├── nested-proxy.ts # Recursive Proxy factory
│ │ └── json-patch.ts # RFC 6902 diff/apply
│ └── stores/
│ └── cpx-scheme-store.ts # Example store
├── test/
│ ├── cpx-store.spec.ts # Core browser tests
│ ├── cpx-store-core.test.ts # Headless Deno tests
│ ├── nested-state.spec.ts # Nested proxy tests
│ ├── history-strategies.spec.ts # History strategy tests
│ ├── collab.spec.ts # Collaboration tests
│ ├── sse.spec.ts # SSE transport tests
│ └── scheme-store.spec.ts # Example store tests
├── demo/ # Demo application
├── deno.json
├── tsconfig.json
└── web-test-runner.config.mjs
deno install # Install dependencies
deno task test:browser # Run browser tests (Chromium + Firefox)
deno test test/cpx-store-core.test.ts # Run headless tests
deno task test:watch # Watch mode
deno task build # Build
deno task serve # Dev server- Chrome/Edge 54+
- Firefox 63+
- Safari 10.1+
SEE LICENSE IN LICENSE
Luke Dary — ldary@redhat.com — lukedary.com