@chapeaux/cpx-store
v0.10.0
Published
Chapeaux Reactive Store Component
Readme
CPX Store
Reactive state management with a plugin architecture, signal-inspired computed properties, and a headless core that runs anywhere JavaScript runs.
Features
- 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
- Typed Selectors and Snapshots — Subscribe to paths or selected immutable state slices
- Framework Adapters — Concurrent-safe React binding with SSR support, plus Vue and Svelte adapters under 300 B each
- 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 and Arrays — Deep mutations and array mutators 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
- Store Composition — Feature domains discover each other through the document or a BroadcastChannel, across bundle boundaries
- Redux DevTools — Per-path diffs, mutation provenance, and time travel
- Zustand Drop-In —
zustand-compatruns existing Zustand code unmodified, so migration is incremental - Zero Core Dependencies — React and Vue are optional peers used only by their own entry points
Installation
# Using JSR (recommended)
deno add jsr:@chapeaux/cpx-store
# Using npm
npm install @chapeaux/cpx-storeQuick Start
Browser — Web Component
import { CPXStore } from "@chapeaux/cpx-store";
import { historyPlugin } from "@chapeaux/cpx-store/plugins/history";
interface AppState {
count: number;
}
class AppStore extends CPXStore<AppState> {
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>Server / CLI — Headless
import { CPXStoreCore } from "@chapeaux/cpx-store/cpx-store-core";
import { historyPlugin } from "@chapeaux/cpx-store/plugins/history";
const store = new CPXStoreCore({ count: 0 }, historyPlugin())
.computed("doubled", (state) => state.count * 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.
Constructor
// Browser
new CPXStore(initialState, ...plugins);
// Headless
new CPXStoreCore(initialState, ...plugins);initialState— Primitives, plain objects, and arrays; class instances, cycles, and shared object references are rejected...plugins— Zero or moreStorePlugininstances
CPXStoreCore infers its state type from initialState. Web Component
subclasses should provide an explicit type, such as CPXStore<AppState>.
Misspelled keys and invalid values then fail during type checking instead of
silently becoming new state properties.
Plugins
Middleware
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.
History
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().
Persistence
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" });
// Persist only durable fields
persistencePlugin({
key: "my-app-state",
partialize: (state) => ({
theme: state.theme,
preferences: state.preferences,
}),
});<app-store persist="my-app-state"></app-store>The plugin merges persisted fields over initial defaults, writes once per flush
(not per mutation), and listens for storage events from other tabs. Fields
omitted by partialize remain local and are not overwritten by cross-tab
updates. In environments without localStorage, storage operations are silently
skipped.
Collaboration
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().
Computed Properties
No dependency array — dependencies are auto-tracked during evaluation.
const cartStore = new CPXStoreCore({ price: 10, qty: 2 })
.computed("total", (state) => state.price * state.qty);
cartStore.state.price = 5;
console.log(cartStore.state.total); // recomputed automaticallyConditional dependencies work correctly:
store.computed("value", (state) => {
return state.useA ? state.a : state.b;
});
// Only tracks state.a when state.useA is trueTransitive dependencies chain automatically:
const doubledStore = new CPXStoreCore({ base: 2 })
.computed("doubled", (state) => state.base * 2);
const store = doubledStore
.computed("quadrupled", (state) => state.doubled * 2);Computed values are included in change sets when their output changes, so path and selector subscriptions can observe them directly. They are readonly and cannot collide with stored keys.
Batching and Transactions
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 action; only the synchronous commit is batched
await store.dispatch(async () => {
const response = await fetch("/api");
const items = await response.json();
store.transaction(() => {
store.state.items = items;
store.state.loading = false;
});
});dispatch() does not hold a global batch open across await. This prevents
unrelated writes and concurrent actions from blocking each other. Use a
synchronous batch() or transaction() around the final state commit when one
notification is required.
Nested State
Deep 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"
store.state.items.push({ id: "new" });
// Array mutators replace the containing array and report prop: "items"Each nested path gets its own reactive signal, so computed values that read
state.editor.file1.content are not invalidated when file2 changes.
Events
Browser (CPXStore)
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 }.
Headless (CPXStoreCore)
const unsub = store.onChange((changes) => {
// changes is a Map<string, { old, val }>
for (const [prop, { old, val }] of changes) {
console.log(`${prop}: ${old} → ${val}`);
}
});
// Only runs for this path, its ancestors, or its descendants
const unsubPath = store.onChange(handler, { paths: ["editor.file1.content"] });
// Selector subscription with equality filtering
const unsubSelector = store.subscribe(
(snapshot) => snapshot.editor.file1.content,
(content, previous) => console.log(content, previous),
);
// Later:
unsub();getSnapshot() returns an immutable, identity-stable snapshot. A new snapshot
is published once per flush and structurally shares unchanged branches.
React
import { useCPXStore } from "@chapeaux/cpx-store/react";
function Counter({ store }) {
const count = useCPXStore(store, (state) => state.count);
return <button onClick={() => store.state.count++}>{count}</button>;
}Selectors run against immutable snapshots, so object and array selections have
stable identities. Pass equalityFn for selectors that intentionally return
fresh objects. For SSR, create one store per request and pass
getServerSnapshot when the server snapshot differs from store.getSnapshot().
Vue
import { useCPXStore } from "@chapeaux/cpx-store/vue";
const count = useCPXStore(store, (state) => state.count);Returns a shallowRef — snapshots are already deep-frozen and structurally
shared, so Vue's deep reactivity would walk a tree that cannot change in place.
The subscription is released automatically when the surrounding effect scope is
disposed; outside a component, call count.stop().
Svelte
<script>
import { toSvelteStore } from "@chapeaux/cpx-store/svelte";
const count = toSvelteStore(store, (state) => state.count);
</script>
<button on:click={() => store.state.count++}>{$count}</button>A plain readable store with no framework import, so $-prefixed
auto-subscription works and the adapter adds 218 B. The subscriber runs only
when the selected slice changes.
Migrating from Zustand
@chapeaux/cpx-store/zustand-compat implements the vanilla Zustand surface over
a real CPX store, so an existing codebase moves one module at a time rather than
in a single rewrite:
- import { createStore } from "zustand/vanilla";
+ import { createStore } from "@chapeaux/cpx-store/zustand-compat";
const useBearStore = createStore((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}));
useBearStore.getState().increase(3);Everything the Zustand surface cannot express is one property away — the real
store is on .cpx, so you can adopt computed values, transactions and
path-filtered subscriptions incrementally:
useBearStore.cpx.computed("crowd", (state) => state.bears > 10);
useBearStore.cpx.onChange((changes) => …, { paths: ["bears"] });Two behavioural differences, stated loudly because silent ones are worse:
setStateflushes inline. Zustand notifies subscribers beforesetStatereturns; CPX normally coalesces writes into a microtask. The shim preserves Zustand's timing, so asetStateloop pays per-call subscriber cost exactly as it does today. Drop the shim for a given store to get the coalescing back.- Actions live on the store, not in the state tree. CPX state holds only
serialisable values, so functions in the initializer are kept beside the state
rather than inside it. They remain reachable from
getState(), but they do not appear incpx.getSnapshot()— which is what makes snapshots structurally comparable and JSON-serialisable.
You do not need an immer equivalent: direct mutation is the API, and the immutable snapshot is produced for you.
Inspector
<script type="module">
import "@chapeaux/cpx-store/inspector";
</script>
<app-store id="store"></app-store>
<cpx-store-inspector for="store"></cpx-store-inspector>Renders tracked paths, current values, subscriber counts, dependent computeds and the flush count. It is a separate entry point and is never imported by the core.
Sync
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;
}
}
}SSR Hydration
// 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);Working with Large Data Structures
The nested proxy system creates signals only for paths read while a computed is tracking dependencies; ordinary read-only traversal no longer grows the signal map. Very large or high-churn collections can still be better served by a purpose-built structure plus a version counter.
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.
Project Structure
cpx-store/
├── src/
│ ├── cpx-store-core.ts # Headless core (CPXStoreCoreMixin + CPXStoreCore)
│ ├── cpx-store.ts # Web Component wrapper (CPXStore)
│ ├── reactivity.ts # ReactiveState, ReactiveComputed
│ ├── react.ts # useSyncExternalStore React adapter
│ ├── vue.ts # shallowRef adapter
│ ├── svelte.ts # Readable-store adapter (no framework import)
│ ├── zustand-compat.ts # Vanilla Zustand surface over a CPX store
│ ├── composition.ts # Store hub, atomic multi-store transactions
│ ├── inspector.ts # <cpx-store-inspector> element
│ ├── types.ts # StorePlugin, SyncTransport, StateOperation
│ ├── plugins/
│ │ ├── middleware.ts # Filterable middleware
│ │ ├── history.ts # Undo/redo with strategies
│ │ ├── persistence.ts # localStorage + cross-tab sync
│ │ ├── devtools.ts # Redux DevTools protocol
│ │ └── 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
│ │ └── element-mesh.ts # DOM-discovered mesh transport
│ ├── utils/
│ │ ├── nested-proxy.ts # Recursive Proxy factory
│ │ ├── immutable.ts # Cloning, frozen snapshots, structural sharing
│ │ └── 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
│ ├── hardening.test.ts # Correctness and regression tests
│ ├── perf-budget.test.ts # Blocking performance gate (operation counts)
│ ├── adapters.test.ts # Zustand-compat and Svelte adapter tests
│ ├── composition.test.ts # Hub, bridge, and DevTools tests
│ ├── react.test.ts # React selector and lifecycle 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.mjsDevelopment
deno install # Install dependencies
deno task test:browser # Run browser tests (Chromium + Firefox)
deno task test:logic # Run headless and React tests
deno task test:types # Compile public type-contract tests
deno task test:watch # Watch mode
deno task build # Build
deno task serve # Dev serverBrowser Support
- Chrome/Edge 54+
- Firefox 63+
- Safari 10.1+
License
SEE LICENSE IN LICENSE
Author
Luke Dary — [email protected] — lukedary.com
